From f98bd61509379cd48acf4f9f656f5e8b66bf4001 Mon Sep 17 00:00:00 2001 From: Yiheng Tao Date: Wed, 10 Jun 2026 06:56:05 -0700 Subject: [PATCH] Consolidate admin settings into a single-definition registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adding an admin-exposed config setting previously required edits in ~10 places (config types/defaults, zod schema, namespace merge + source keys, admin field specs, runtime feature flags, DashboardOptions, html-template embed, SPA config plumbing, AdminPanel state/JSX, plus per-setting tests). A setting is now ONE entry in config/admin-setting-definitions.ts (value spec, default, runtime behavior, optional runtimeFlag and Features-card UI metadata) plus its CLIConfig/ResolvedCLIConfig/DEFAULT_CONFIG declaration: - admin-config-fields.ts derives validate/apply specs from the registry - schema.ts generates nested zod fragments and deep-merges them with a hand-written base tree of non-admin fields - namespace-registry.ts merges dotted registry keys generically (override ?? base ?? default) and tracks file/default sources by path; hand-written descriptors remain only for non-admin namespaces and customMerge settings (agentProviderRouting.auto) - buildRuntimeFeatures() builds RuntimeDashboardConfig.features from runtimeFlag entries; spaHtml embeds the whole map as __DASHBOARD_CONFIG__.features (JSON, <-escaped) and utils/config.ts flattens it generically — per-flag plumbing through DashboardOptions/ html-template/utils-config is gone - the AdminPanel Features card renders groups/rows/badges/selects from registry UI metadata over a generic featureValues record (state, dirty, save payload, cancel all generic); exact data-testids preserved - absentFallback preserves bootstrap-conservative reads for partial configs (e.g. workItems.hierarchy.enabled) - parallel admin validation now requires an integer, matching the file schema it previously disagreed with Tests: new generic contract suite (test/config/admin-setting-definitions .test.ts, 432 generated tests) verifies every setting end-to-end — DEFAULT_CONFIG consistency, schema accept/reject, validate/apply round-trip, merge override, source tracking, runtime flag exposure, and UI metadata integrity — so new settings get coverage automatically. Embed/source-grep tests updated to the features-map embed and registry. Co-Authored-By: Claude Fable 5 --- .../coc-knowledge/references/admin-config.md | 29 +- packages/coc/AGENTS.md | 19 +- .../src/config/admin-setting-definitions.ts | 740 ++++++++++++++++++ packages/coc/src/config/namespace-registry.ts | 355 ++------- packages/coc/src/config/schema.ts | 332 ++++---- .../src/server/admin/admin-config-fields.ts | 451 ++--------- .../server/config/runtime-config-handler.ts | 51 +- packages/coc/src/server/index.ts | 28 +- .../spa/client/react/admin/AdminPanel.tsx | 520 ++---------- .../server/spa/client/react/utils/config.ts | 66 +- packages/coc/src/server/spa/html-template.ts | 61 +- packages/coc/src/server/spa/types.ts | 59 +- .../config/admin-setting-definitions.test.ts | 318 ++++++++ packages/coc/test/server/spa-html.test.ts | 26 +- .../AdminPanel-work-items-sync-copy.test.ts | 36 +- .../repos/WorkItemAiAcCompliance.test.ts | 21 +- .../test/spa/react/terminal-config.test.ts | 21 +- 17 files changed, 1573 insertions(+), 1560 deletions(-) create mode 100644 packages/coc/src/config/admin-setting-definitions.ts create mode 100644 packages/coc/test/config/admin-setting-definitions.test.ts diff --git a/.github/skills/coc-knowledge/references/admin-config.md b/.github/skills/coc-knowledge/references/admin-config.md index 4c4cba91f..ff5957e6c 100644 --- a/.github/skills/coc-knowledge/references/admin-config.md +++ b/.github/skills/coc-knowledge/references/admin-config.md @@ -2,21 +2,30 @@ Covers the editable admin config registry in `packages/coc/` and the self-contained styling system for the admin route in the dashboard SPA. Load this when adding or modifying any admin-exposed configuration field or admin UI element. -## Admin Config Field Registry +## Unified Admin Setting Registry -Editable admin config fields are defined in a single registry: `packages/coc/src/server/admin/admin-config-fields.ts` (`ADMIN_CONFIG_FIELDS`). +Admin-editable settings have ONE source of truth: `packages/coc/src/config/admin-setting-definitions.ts` (`ADMIN_SETTING_DEFINITIONS`). Each definition declares the flat key (e.g. `'loops.enabled'`), a value spec (`boolean` | `string` | `number` | `enum` | `custom`) from which validation derives, the resolved `default`, the `runtime` behavior (`live`/`restartRequired`), an optional `absentFallback` (value assumed when a partial config lacks the key — used for bootstrap-conservative flags whose resolved default is on), an optional `runtimeFlag` (property name in `RuntimeDashboardConfig.features`), an optional `customMerge` escape hatch, and optional `ui` metadata (group/order/label/hint/badge/dependsOn/control/testId) for the admin Features card. The file is dependency-free (no Node/zod imports) because the SPA bundle imports it directly. -Each entry provides a flat key (e.g. `'loops.enabled'`), a field-local `validate()` function, and an `apply()` function. The `PUT /api/admin/config` handler derives `editableKeys`, field-local validation, and merge logic from this registry. Cross-field constraints belong in `CLIConfigSchema`/`validateConfigWithSchema()` and the admin write path re-validates the merged config before persisting so admin updates and config-file loading reject the same invalid combinations. +Everything else derives from the registry: -To expose a new config field via the admin API, add ONE entry to `ADMIN_CONFIG_FIELDS`. Also update: +- `packages/coc/src/server/admin/admin-config-fields.ts` maps definitions to `ADMIN_CONFIG_FIELDS` (validate/apply specs); the `PUT /api/admin/config` handler and `RuntimeConfigService` consume it unchanged. +- `packages/coc/src/config/schema.ts` generates nested zod fragments per setting and deep-merges them with a hand-written base tree that only declares non-admin fields (queue, models, logging, monitoring, skills, memoryPromotion, serve host/port, `features.autoMemoryPromotion`/`gitCommitLookup`). Settings with `kind: 'custom'` must register a file schema in `CUSTOM_FILE_SCHEMAS` there. +- `packages/coc/src/config/namespace-registry.ts` merges every dotted registry key generically (`override ?? base ?? default`) and tracks its `file`/`default` source by path; hand-written namespace descriptors remain only for non-admin sections and `agentProviderRouting.auto` (`customMerge`). +- `buildRuntimeFeatures()` in `packages/coc/src/server/config/runtime-config-handler.ts` builds `RuntimeDashboardConfig.features` from all `runtimeFlag` definitions (plus the hand-mapped non-admin `gitCommitLookupEnabled`). Both `GET /api/config/runtime` and the `spaHtml` bootstrap embed in `packages/coc/src/server/index.ts` use it; the HTML template embeds the whole map as `window.__DASHBOARD_CONFIG__.features` (JSON, `<`-escaped) and the SPA `utils/config.ts` flattens it generically — no per-flag plumbing through `DashboardOptions`/`html-template`/`utils/config`. New flags are readable client-side via `isFeatureEnabled('xyzEnabled')` or a typed accessor. +- The admin Features card in `AdminPanel.tsx` renders groups/rows/badges/selects from the registry `ui` metadata; row state, dirty tracking, save payload (flat keys), and cancel all operate on a generic `featureValues` record. +- `packages/coc/test/config/admin-setting-definitions.test.ts` runs generic contract tests over every definition (DEFAULT_CONFIG consistency, schema accept/reject, validate/apply round-trip, merge override, source tracking, runtime flag exposure, UI metadata integrity) — a new setting gets this coverage automatically. -1. `CLIConfig` / `ResolvedCLIConfig` / `DEFAULT_CONFIG` in `packages/coc/src/config.ts` -2. `CLIConfigSchema` in `packages/coc/src/config/schema.ts` -3. Namespace registry in `packages/coc/src/config/namespace-registry.ts` (nested fields) -4. `AdminResolvedConfig` / `AdminConfigUpdate` in `packages/coc-client/src/contracts/admin.ts` -5. `AdminPanel.tsx` or the focused admin subpage component for the UI control +To add a new admin-exposed setting: -The `spaHtml` function in `packages/coc/src/server/index.ts` re-reads the config file on every page request, so feature-flag changes (e.g. `terminal.enabled`) take effect on the next browser reload — no server restart required. +1. Add the field to `CLIConfig` / `ResolvedCLIConfig` / `DEFAULT_CONFIG` in `packages/coc/src/config.ts` (a contract test fails if the registry default and `DEFAULT_CONFIG` disagree). +2. Add ONE definition entry to `ADMIN_SETTING_DEFINITIONS`. Include `ui` to surface it on the Features card and `runtimeFlag` to expose it to the dashboard. +3. Only if `runtimeFlag` is set: add the flag name to `RuntimeDashboardConfig.features` in `packages/coc-client/src/contracts/admin.ts` (cross-package type; `AdminResolvedConfig`/`AdminConfigUpdate` absorb new keys via their index signatures). + +Behavior-specific tests (what the flag gates) still belong with the feature; the standard setting contract needs no new tests. + +Cross-field constraints belong in `CLIConfigSchema`/`validateConfigWithSchema()` and the admin write path re-validates the merged config before persisting so admin updates and config-file loading reject the same invalid combinations. + +The `spaHtml` function in `packages/coc/src/server/index.ts` reads the RuntimeConfigService snapshot on every page request, so feature-flag changes (e.g. `terminal.enabled`) take effect on the next browser reload — no server restart required. Work Items expose live flags through this path: `workItems.hierarchy.enabled` enables the hierarchy board, `workItems.sync.enabled` enables remote provider integration, `workItems.aiAuthoring.enabled` enables AI-assisted authoring, and `workItems.workflow.enabled` gates the durable Work Items/Goals workflow command center. Sync UI helpers treat provider integration as enabled only when both hierarchy and sync flags are true; provider credentials stay external and are not admin config fields. The durable workflow flag is disabled by default and should gate new Work Items/Goals workflow behavior so existing Chat and Work Items behavior stays unchanged while the flag is off. Pull Requests exposes `pullRequests.enabled`, `pullRequests.suggestions`, and `pullRequests.autoClassifyTeam` through Admin -> Configure -> Features; auto-classifying Team PRs is disabled by default. Dedicated mode flags such as `forEach.enabled` and `mapReduce.enabled` live as top-level namespaces and are disabled by default. Experimental dashboard/chat flags live under `features.*`; `features.gitCrossCloneCherryPick` enables the cross-clone cherry-pick commit context-menu modal and is enabled by default, `features.sessionContextAttachments` enables drag/drop session-context attachments in chat composers and is disabled by default, and `features.commitChatLens` enables desktop review-chat lens placement for supported commit and PR chat surfaces and is disabled by default. `features.commitChatLensDormantMode` (`'ghost'` | `'pill'`, default `'ghost'`) controls how the lens recedes when the cursor leaves: ghost fades to near-transparent with scale-down, pill collapses to a compact status pill. `features.autoAgentProviderRouting` is edited from Admin -> AI Provider, enables Auto provider routing, and is disabled by default. diff --git a/packages/coc/AGENTS.md b/packages/coc/AGENTS.md index a28b93454..ba0218ada 100644 --- a/packages/coc/AGENTS.md +++ b/packages/coc/AGENTS.md @@ -44,11 +44,20 @@ all have their own `references/*.md`. Claude Code discovers them as slash commands. A sidecar marker `.coc-.json` tracks CoC-managed commands to distinguish them from user-authored ones. -- **Adding an editable config field** is usually a single registry entry. Put - field-local validation in `src/server/admin/admin-config-fields.ts`; reserve - `admin-handler.ts` changes for cross-field validation shared with config-file - loading (see [admin-config.md](../../.github/skills/coc-knowledge/references/admin-config.md)). -- **Adding a namespaced config field** must update +- **Adding an admin-exposed config setting** is ONE definition entry in + `src/config/admin-setting-definitions.ts` (value spec, default, runtime, + optional `runtimeFlag` + Features-card `ui` metadata) plus the + `CLIConfig`/`ResolvedCLIConfig`/`DEFAULT_CONFIG` declarations in + `src/config.ts`. Admin validation, file schema, namespace merge/source + tracking, runtime feature flags, the embedded SPA bootstrap, the Features + card UI, and the generic contract tests + (`test/config/admin-setting-definitions.test.ts`) all derive from the + registry — do not hand-edit `admin-config-fields.ts`, `schema.ts` leaves, + or `namespace-registry.ts` for admin settings. Reserve `admin-handler.ts` + changes for cross-field validation shared with config-file loading (see + [admin-config.md](../../.github/skills/coc-knowledge/references/admin-config.md)). +- **Non-admin namespaced config fields** (queue, models, logging, monitoring, + skills, memoryPromotion, …) keep hand-written descriptors in `src/config/namespace-registry.ts`; do not expand branch lists in `config.ts`. - **MCP REST surface** must never expose secrets (`env`, headers, full `args`). - **Ralph iteration prompts** must not hard-code implementation skill names diff --git a/packages/coc/src/config/admin-setting-definitions.ts b/packages/coc/src/config/admin-setting-definitions.ts new file mode 100644 index 000000000..869ce1def --- /dev/null +++ b/packages/coc/src/config/admin-setting-definitions.ts @@ -0,0 +1,740 @@ +/** + * Admin Setting Definitions — single source of truth for admin-editable config. + * + * ONE entry here drives everything derived elsewhere: + * - PUT /api/admin/config validation + merge (server/admin/admin-config-fields.ts) + * - config-file schema validation (config/schema.ts) + * - resolved-config merge + source tracking (config/namespace-registry.ts) + * - runtime dashboard feature flags + SPA embed (server/config/runtime-config-handler.ts, server/index.ts) + * - the admin Features card UI (server/spa/client/react/admin/AdminPanel.tsx) + * - the generic contract test suite (test/config/admin-setting-definitions.test.ts) + * + * To add a new admin-exposed setting: + * 1. Add the field to CLIConfig / ResolvedCLIConfig and DEFAULT_CONFIG in config.ts + * (compile-time shape + default; a contract test enforces consistency). + * 2. Add ONE definition entry below. Set `ui` to surface it on the admin + * Features card, and `runtimeFlag` to expose it to the dashboard SPA. + * 3. Only if `runtimeFlag` is set: add the flag to RuntimeDashboardConfig.features + * in coc-client/src/contracts/admin.ts (cross-package type). + * + * This module must stay free of Node and zod imports — it is bundled into the + * dashboard SPA client. + */ + +import type { CLIConfig } from '../config'; + +/** Runtime behavior classification for admin-editable config fields. */ +export type AdminConfigFieldRuntime = 'live' | 'reloadable' | 'restartRequired'; + +// ── value specs ─────────────────────────────────────────────────────────────── + +export type AdminSettingValueSpec = + | { kind: 'boolean' } + | { + kind: 'string'; + nonEmpty?: boolean; + maxLength?: number; + /** Accept null/undefined; applying null clears the stored value. */ + nullable?: boolean; + /** Applying '' also clears the stored value (requires nullable). */ + clearOnEmpty?: boolean; + /** Validation error message override. */ + message?: string; + } + | { + kind: 'number'; + integer?: boolean; + /** Exclusive lower bound (value must be strictly greater). */ + gt?: number; + min?: number; + max?: number; + /** Accept null; applying null clears the stored value. */ + nullable?: boolean; + /** Validation error message override. */ + message?: string; + } + | { + kind: 'enum'; + values: readonly string[]; + /** Validation error message override. */ + message?: string; + } + | { + kind: 'custom'; + validate: (value: unknown) => string | undefined; + }; + +// ── UI specs (admin Features card) ──────────────────────────────────────────── + +export type FeatureGroupId = 'dashboard' | 'devTools' | 'workItems' | 'aiModes' | 'review' | 'infrastructure'; + +export interface FeatureGroupSpec { + id: FeatureGroupId; + heading: string; + testId: string; +} + +/** Ordered groups rendered in the admin Features card. */ +export const FEATURE_CARD_GROUPS: readonly FeatureGroupSpec[] = [ + { id: 'dashboard', heading: 'Dashboard Modules', testId: 'feature-group-dashboard' }, + { id: 'devTools', heading: 'Development Tools', testId: 'feature-group-dev-tools' }, + { id: 'workItems', heading: 'Work Items', testId: 'feature-group-work-items' }, + { id: 'aiModes', heading: 'AI Execution Modes', testId: 'feature-group-ai-modes' }, + { id: 'review', heading: 'Code Review & Collaboration', testId: 'feature-group-review' }, + { id: 'infrastructure', heading: 'Infrastructure', testId: 'feature-group-infrastructure' }, +]; + +export type AdminSettingBadge = 'restart' | 'experimental' | 'preview'; + +export interface AdminSettingUiSpec { + group: FeatureGroupId; + /** Render order within the group (ascending). */ + order: number; + label: string; + hint: string; + badge?: AdminSettingBadge; + /** Only render when this other (boolean) setting is currently on. */ + dependsOn?: string; + /** Defaults to a toggle when omitted. */ + control?: { type: 'select'; options: readonly { value: string; label: string }[] }; + testId: string; +} + +// ── definition ──────────────────────────────────────────────────────────────── + +export interface AdminSettingDefinition { + /** Flat dot-notation key used in PUT /api/admin/config, e.g. 'loops.enabled'. */ + key: string; + value: AdminSettingValueSpec; + /** Resolved default — must match DEFAULT_CONFIG (enforced by contract test). */ + default: unknown; + /** Runtime behavior: 'live' (immediate), 'reloadable', or 'restartRequired'. */ + runtime: AdminConfigFieldRuntime; + /** + * Value assumed when the config object has no value at `key`. + * Used by the runtime feature-flag builder and the admin UI loader. + * Defaults to `default`; override only for bootstrap-conservative flags + * that must read as off/legacy when absent from a partial config. + */ + absentFallback?: unknown; + /** + * Property name in RuntimeDashboardConfig.features. When set, the value is + * exposed to the dashboard SPA (embedded bootstrap + GET /api/config/runtime). + */ + runtimeFlag?: string; + /** + * Skip the generic resolved-config merge for this key — a hand-written + * namespace descriptor in namespace-registry.ts owns its resolution. + */ + customMerge?: boolean; + /** Admin Features card exposure. Omit for settings rendered bespoke elsewhere. */ + ui?: AdminSettingUiSpec; +} + +// ── path helpers ────────────────────────────────────────────────────────────── + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** Read the value at a dot-notation path, or undefined when absent. */ +export function getConfigValueAtPath(config: unknown, key: string): unknown { + let current: unknown = config; + for (const segment of key.split('.')) { + if (!isPlainObject(current)) return undefined; + current = current[segment]; + } + return current; +} + +/** Write a value at a dot-notation path, creating intermediate objects. */ +export function setConfigValueAtPath(config: Record, key: string, value: unknown): void { + const segments = key.split('.'); + let current: Record = config; + for (const segment of segments.slice(0, -1)) { + if (!isPlainObject(current[segment])) { + current[segment] = {}; + } + current = current[segment] as Record; + } + current[segments[segments.length - 1]] = value; +} + +/** Delete the value at a dot-notation path. No-op when a container is absent. */ +export function deleteConfigValueAtPath(config: Record, key: string): void { + const segments = key.split('.'); + let current: unknown = config; + for (const segment of segments.slice(0, -1)) { + if (!isPlainObject(current)) return; + current = current[segment]; + } + if (isPlainObject(current)) { + delete current[segments[segments.length - 1]]; + } +} + +// ── validation / apply derivation ───────────────────────────────────────────── + +function numberMessage(key: string, spec: Extract): string { + if (spec.message) return spec.message; + let base: string; + if (spec.integer && spec.min !== undefined && spec.max !== undefined) { + base = `${key} must be an integer between ${spec.min} and ${spec.max}`; + } else if (spec.integer && spec.min !== undefined) { + base = `${key} must be a positive integer (≥ ${spec.min})`; + } else if (spec.gt !== undefined) { + base = `${key} must be a number greater than ${spec.gt}`; + } else { + base = `${key} must be a number`; + } + return spec.nullable ? `${base}, or null to clear` : base; +} + +function stringMessage(key: string, spec: Extract): string { + if (spec.message) return spec.message; + let base: string; + if (spec.nonEmpty) { + base = `${key} must be a non-empty string`; + } else if (spec.maxLength !== undefined) { + base = `${key} must be a string of at most ${spec.maxLength} characters`; + } else { + base = `${key} must be a string`; + } + return spec.nullable ? `${base}, or null to clear` : base; +} + +/** Validate a candidate value against a definition. Returns an error message or undefined. */ +export function validateAdminSettingValue(def: AdminSettingDefinition, value: unknown): string | undefined { + const spec = def.value; + switch (spec.kind) { + case 'boolean': + return typeof value === 'boolean' ? undefined : `${def.key} must be a boolean`; + case 'string': { + if (spec.nullable && (value === null || value === undefined)) return undefined; + const ok = typeof value === 'string' + && (!spec.nonEmpty || value.length > 0) + && (spec.maxLength === undefined || value.length <= spec.maxLength); + return ok ? undefined : stringMessage(def.key, spec); + } + case 'number': { + if (spec.nullable && (value === null || value === undefined)) return undefined; + const ok = typeof value === 'number' + && (!spec.integer || Number.isInteger(value)) + && (spec.gt === undefined || value > spec.gt) + && (spec.min === undefined || value >= spec.min) + && (spec.max === undefined || value <= spec.max); + return ok ? undefined : numberMessage(def.key, spec); + } + case 'enum': { + const ok = typeof value === 'string' && spec.values.includes(value); + return ok ? undefined : (spec.message ?? `${def.key} must be one of: ${spec.values.join(', ')}`); + } + case 'custom': + return spec.validate(value); + } +} + +/** Whether applying this (already-validated) value clears the stored field. */ +function clearsStoredValue(def: AdminSettingDefinition, value: unknown): boolean { + const spec = def.value; + if (spec.kind === 'number' && spec.nullable) return value === null; + if (spec.kind === 'string' && spec.nullable) { + return value === null || (spec.clearOnEmpty === true && value === ''); + } + return false; +} + +/** Write an (already-validated) value into the CLIConfig that will be persisted. */ +export function applyAdminSettingValue(config: CLIConfig, def: AdminSettingDefinition, value: unknown): void { + const target = config as unknown as Record; + if (clearsStoredValue(def, value)) { + deleteConfigValueAtPath(target, def.key); + } else { + setConfigValueAtPath(target, def.key, value); + } +} + +// ── reading values back (runtime flags + admin UI) ──────────────────────────── + +/** + * Read the current value for a setting from a (possibly partial) config object, + * falling back to `absentFallback ?? default` when absent or invalid. + */ +export function readAdminSettingValue(def: AdminSettingDefinition, config: unknown): unknown { + const raw = getConfigValueAtPath(config, def.key); + if (raw !== undefined && validateAdminSettingValue(def, raw) === undefined) { + return raw; + } + return def.absentFallback !== undefined ? def.absentFallback : def.default; +} + +/** + * Build the RuntimeDashboardConfig.features flags derived from the registry. + * Flags not backed by an admin setting (e.g. gitCommitLookupEnabled) are added + * by the caller. + */ +export function buildRuntimeFeatureFlags(config: unknown): Record { + const flags: Record = {}; + for (const def of ADMIN_SETTING_DEFINITIONS) { + if (def.runtimeFlag) { + flags[def.runtimeFlag] = readAdminSettingValue(def, config); + } + } + return flags; +} + +// ── agentProviderRouting.auto custom validation ─────────────────────────────── + +const VALID_CONCRETE_PROVIDER_VALUES = ['copilot', 'codex', 'claude'] as const; + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isConcreteProvider(value: unknown): boolean { + return typeof value === 'string' && (VALID_CONCRETE_PROVIDER_VALUES as readonly string[]).includes(value); +} + +function validatePercent(value: unknown, key: string): string | undefined { + return typeof value === 'number' && Number.isInteger(value) && value >= 0 && value <= 100 + ? undefined + : `${key} must be an integer between 0 and 100`; +} + +function validateAutoProviderRouting(value: unknown): string | undefined { + if (!isObject(value)) { + return 'agentProviderRouting.auto must be an object'; + } + const rules = value.rules; + if (rules !== undefined) { + if (!Array.isArray(rules)) { + return 'agentProviderRouting.auto.rules must be an array'; + } + for (const [index, rule] of rules.entries()) { + if (!isObject(rule)) { + return `agentProviderRouting.auto.rules[${index}] must be an object`; + } + if (!isConcreteProvider(rule.provider)) { + return `agentProviderRouting.auto.rules[${index}].provider must be one of: ${VALID_CONCRETE_PROVIDER_VALUES.join(', ')}`; + } + if (rule.enabled !== undefined && typeof rule.enabled !== 'boolean') { + return `agentProviderRouting.auto.rules[${index}].enabled must be a boolean`; + } + if (rule.minimumRemainingPercent !== undefined) { + const err = validatePercent(rule.minimumRemainingPercent, `agentProviderRouting.auto.rules[${index}].minimumRemainingPercent`); + if (err) { return err; } + } + if (rule.weeklyGuard !== undefined) { + if (!isObject(rule.weeklyGuard)) { + return `agentProviderRouting.auto.rules[${index}].weeklyGuard must be an object`; + } + if (rule.weeklyGuard.enabled !== undefined && typeof rule.weeklyGuard.enabled !== 'boolean') { + return `agentProviderRouting.auto.rules[${index}].weeklyGuard.enabled must be a boolean`; + } + if (rule.weeklyGuard.minimumRemainingPercent !== undefined) { + const err = validatePercent(rule.weeklyGuard.minimumRemainingPercent, `agentProviderRouting.auto.rules[${index}].weeklyGuard.minimumRemainingPercent`); + if (err) { return err; } + } + } + } + } + if (value.fallbackProvider !== undefined && !isConcreteProvider(value.fallbackProvider)) { + return `agentProviderRouting.auto.fallbackProvider must be one of: ${VALID_CONCRETE_PROVIDER_VALUES.join(', ')}`; + } + return undefined; +} + +const DEFAULT_AUTO_PROVIDER_ROUTING = { + rules: [ + { provider: 'claude', enabled: true, minimumRemainingPercent: 33, weeklyGuard: { enabled: true, minimumRemainingPercent: 33 } }, + { provider: 'codex', enabled: true, minimumRemainingPercent: 33, weeklyGuard: { enabled: true, minimumRemainingPercent: 33 } }, + { provider: 'copilot', enabled: true, minimumRemainingPercent: 10, weeklyGuard: { enabled: true, minimumRemainingPercent: 10 } }, + ], + fallbackProvider: 'copilot', +} as const; + +// ── shared value helpers ────────────────────────────────────────────────────── + +const bool = (def: Omit): AdminSettingDefinition => ({ + ...def, + value: { kind: 'boolean' }, +}); + +// ── the registry ────────────────────────────────────────────────────────────── + +/** + * All admin-editable config settings. + * + * `absentFallback` overrides encode today's bootstrap-conservative reads: these + * flags read as off/legacy when a partial config lacks them, even though the + * resolved default is on. Resolved configs always carry every field, so the + * fallbacks only matter for partial configs (tests, legacy snapshots). + */ +export const ADMIN_SETTING_DEFINITIONS: readonly AdminSettingDefinition[] = [ + // ── AI execution ────────────────────────────────────────────────────────── + { + key: 'model', + value: { kind: 'string', nonEmpty: true }, + default: undefined, + runtime: 'live', + }, + { + // integer matches the file schema; the admin API previously accepted + // decimals it could not re-load from disk. + key: 'parallel', + value: { kind: 'number', integer: true, gt: 0 }, + default: 5, + runtime: 'live', + }, + { + key: 'timeout', + value: { kind: 'number', gt: 0, nullable: true }, + default: undefined, + runtime: 'live', + }, + { + key: 'output', + value: { kind: 'enum', values: ['table', 'json', 'csv', 'markdown'] }, + default: 'table', + runtime: 'live', + }, + + // ── display / UI ────────────────────────────────────────────────────────── + bool({ key: 'showReportIntent', default: false, runtime: 'live' }), + { + key: 'toolCompactness', + value: { kind: 'number', integer: true, min: 0, max: 3, message: 'toolCompactness must be 0, 1, 2, or 3' }, + default: 3, + runtime: 'live', + }, + { + key: 'taskCardDensity', + value: { kind: 'enum', values: ['compact', 'dense'], message: 'taskCardDensity must be "compact" or "dense"' }, + default: 'dense', + runtime: 'live', + }, + bool({ key: 'groupSingleLineMessages', default: true, runtime: 'live' }), + + // ── serve ───────────────────────────────────────────────────────────────── + { + key: 'serve.serverName', + value: { kind: 'string', maxLength: 64, nullable: true, clearOnEmpty: true }, + default: undefined, + runtime: 'live', + }, + + // ── chat ────────────────────────────────────────────────────────────────── + bool({ key: 'chat.followUpSuggestions.enabled', default: true, runtime: 'live' }), + { + key: 'chat.followUpSuggestions.count', + value: { kind: 'number', integer: true, min: 1, max: 5 }, + default: 3, + runtime: 'live', + }, + bool({ key: 'chat.askUser.enabled', default: true, runtime: 'live' }), + + // ── feature flags ───────────────────────────────────────────────────────── + bool({ + key: 'terminal.enabled', default: true, runtime: 'restartRequired', runtimeFlag: 'terminalEnabled', + ui: { + group: 'devTools', order: 10, label: 'Terminal', badge: 'restart', + hint: 'Web terminal for shell access to the server machine. Toggling requires a server restart.', + testId: 'toggle-terminal-enabled', + }, + }), + bool({ + key: 'notes.enabled', default: true, runtime: 'live', runtimeFlag: 'notesEnabled', + ui: { + group: 'dashboard', order: 10, label: 'Notes', + hint: 'Markdown notebooks for creating and editing notes.', + testId: 'toggle-notes-enabled', + }, + }), + bool({ + key: 'myWork.enabled', default: false, runtime: 'live', runtimeFlag: 'myWorkEnabled', + ui: { + group: 'dashboard', order: 20, label: 'My Work', + hint: 'Personal landing page with action items and weekly summaries.', + testId: 'toggle-mywork-enabled', + }, + }), + bool({ + key: 'myLife.enabled', default: false, runtime: 'live', runtimeFlag: 'myLifeEnabled', + ui: { + group: 'dashboard', order: 30, label: 'My Life', + hint: 'Personal page with goals, journal, and life admin.', + testId: 'toggle-mylife-enabled', + }, + }), + bool({ + key: 'scratchpad.enabled', default: true, absentFallback: false, runtime: 'live', runtimeFlag: 'scratchpadEnabled', + ui: { + group: 'dashboard', order: 40, label: 'Scratchpad panel', + hint: 'Bottom-split note editor inside the chat detail view.', + testId: 'toggle-scratchpad-enabled', + }, + }), + { + key: 'scratchpad.layout', + value: { kind: 'enum', values: ['horizontal', 'vertical'], message: 'scratchpad.layout must be "horizontal" or "vertical"' }, + default: 'vertical', + absentFallback: 'horizontal', + runtime: 'live', + runtimeFlag: 'scratchpadLayout', + ui: { + group: 'dashboard', order: 50, label: 'Layout', dependsOn: 'scratchpad.enabled', + hint: 'Split direction for conversation and scratchpad.', + control: { + type: 'select', + options: [ + { value: 'horizontal', label: 'Horizontal (top/bottom)' }, + { value: 'vertical', label: 'Vertical (left/right)' }, + ], + }, + testId: 'select-scratchpad-layout', + }, + }, + bool({ + key: 'workflows.enabled', default: false, runtime: 'live', runtimeFlag: 'workflowsEnabled', + ui: { + group: 'devTools', order: 20, label: 'Workflows Tab', + hint: 'YAML workflow runner tab in repo view.', + testId: 'toggle-workflows-enabled', + }, + }), + bool({ + key: 'pullRequests.enabled', default: true, absentFallback: false, runtime: 'live', runtimeFlag: 'pullRequestsEnabled', + ui: { + group: 'devTools', order: 30, label: 'Pull Requests Tab', + hint: 'Pull request list tab in repo view.', + testId: 'toggle-pull-requests-enabled', + }, + }), + bool({ + key: 'pullRequests.suggestions', default: false, runtime: 'live', runtimeFlag: 'pullRequestsSuggestionsEnabled', + ui: { + group: 'devTools', order: 40, label: 'PR Review Suggestions', dependsOn: 'pullRequests.enabled', + hint: "AI-ranked suggestions for which open PRs to review, based on your review history. Adds a 'For You' filter pill to the PR queue.", + testId: 'toggle-pull-requests-suggestions-enabled', + }, + }), + bool({ + key: 'pullRequests.autoClassifyTeam', default: false, runtime: 'live', runtimeFlag: 'pullRequestsAutoClassifyTeamEnabled', + ui: { + group: 'devTools', order: 50, label: 'Auto-classify Team PRs', dependsOn: 'pullRequests.enabled', + hint: 'Automatically queues lightweight diff classification for open Pull Requests tab Team roster PRs. Disabled by default.', + testId: 'toggle-pull-requests-auto-classify-team-enabled', + }, + }), + bool({ + key: 'servers.enabled', default: true, absentFallback: false, runtime: 'live', runtimeFlag: 'serversEnabled', + ui: { + group: 'devTools', order: 60, label: 'Servers', + hint: 'Multi-server connection manager (devtunnel).', + testId: 'toggle-servers-enabled', + }, + }), + bool({ + key: 'ralph.enabled', default: false, runtime: 'live', runtimeFlag: 'ralphEnabled', + ui: { + group: 'aiModes', order: 10, label: 'Ralph Mode', badge: 'experimental', + hint: 'Autonomous iterative coding loop — stateless agents with fresh context per iteration.', + testId: 'toggle-ralph-enabled', + }, + }), + bool({ + key: 'forEach.enabled', default: false, runtime: 'live', runtimeFlag: 'forEachEnabled', + ui: { + group: 'aiModes', order: 20, label: 'For Each Mode', badge: 'experimental', + hint: 'Generate a reviewed item plan from New Chat, then run each item as a separate child chat. Disabled by default.', + testId: 'toggle-for-each-enabled', + }, + }), + bool({ + key: 'mapReduce.enabled', default: false, runtime: 'live', runtimeFlag: 'mapReduceEnabled', + ui: { + group: 'aiModes', order: 30, label: 'Map Reduce Mode', badge: 'experimental', + hint: 'Generate a reviewed map plan from New Chat, run items in parallel, then reduce outputs into one result. Disabled by default.', + testId: 'toggle-map-reduce-enabled', + }, + }), + { + key: 'ralph.finalCheck.maxGapFixLoops', + value: { kind: 'number', integer: true, min: 1 }, + default: 3, + runtime: 'live', + }, + bool({ + key: 'vimNavigation.enabled', default: false, runtime: 'live', runtimeFlag: 'vimNavigationEnabled', + ui: { + group: 'infrastructure', order: 40, label: 'Vim-style navigation', + hint: 'Enable hjkl pane navigation, j/k to step through chats and messages, gg/G to jump, i to focus the input, Esc to blur. Disabled by default.', + testId: 'toggle-vim-navigation-enabled', + }, + }), + bool({ + key: 'loops.enabled', default: true, absentFallback: false, runtime: 'restartRequired', runtimeFlag: 'loopsEnabled', + ui: { + group: 'infrastructure', order: 10, label: 'Loops & Wakeups', badge: 'restart', + hint: 'Recurring follow-up loops and one-shot scheduleWakeup tool. Disabled by default — toggling requires a server restart to (de)wire infrastructure.', + testId: 'toggle-loops-enabled', + }, + }), + bool({ + key: 'excalidraw.enabled', default: false, runtime: 'live', runtimeFlag: 'excalidrawEnabled', + ui: { + group: 'review', order: 60, label: 'Excalidraw diagrams', + hint: 'AI can generate and read Excalidraw diagrams during conversations. Disabled by default.', + testId: 'toggle-excalidraw-enabled', + }, + }), + bool({ + key: 'mcpOauth.enabled', default: false, runtime: 'restartRequired', runtimeFlag: 'mcpOauthEnabled', + ui: { + group: 'infrastructure', order: 20, label: 'MCP OAuth', badge: 'restart', + hint: 'Handle OAuth flows for MCP servers that require authentication. Disabled by default — toggling requires a server restart.', + testId: 'toggle-mcp-oauth-enabled', + }, + }), + bool({ + key: 'mcpOauth.autoRefresh.enabled', default: false, runtime: 'restartRequired', + ui: { + group: 'infrastructure', order: 30, label: 'MCP OAuth auto-refresh', badge: 'restart', dependsOn: 'mcpOauth.enabled', + hint: "Periodically dedup ~/.copilot/mcp-oauth-config/ and refresh AAD-backed tokens before they expire so HTTP MCP servers don't re-prompt for auth. Disabled by default — toggling requires a server restart.", + testId: 'toggle-mcp-oauth-auto-refresh-enabled', + }, + }), + bool({ key: 'containerDefaultAgent.enabled', default: false, runtime: 'live', runtimeFlag: 'containerDefaultAgentEnabled' }), + bool({ key: 'codex.enabled', default: false, runtime: 'live', runtimeFlag: 'codexEnabled' }), + bool({ key: 'claude.enabled', default: false, runtime: 'live', runtimeFlag: 'claudeEnabled' }), + { + key: 'defaultProvider', + value: { kind: 'enum', values: ['copilot', 'codex', 'claude'], message: 'defaultProvider must be "copilot", "codex", or "claude"' }, + default: 'copilot', + runtime: 'restartRequired', + runtimeFlag: 'defaultProvider', + }, + { + key: 'agentProviderRouting.auto', + value: { kind: 'custom', validate: validateAutoProviderRouting }, + default: DEFAULT_AUTO_PROVIDER_ROUTING, + runtime: 'restartRequired', + customMerge: true, + }, + + bool({ + key: 'features.focusedDiff', default: false, runtime: 'live', runtimeFlag: 'focusedDiffEnabled', + ui: { + group: 'review', order: 10, label: 'Focused Diff', + hint: 'AI-powered hunk classification for PR diffs. Highlights logic changes and dims mechanical edits.', + testId: 'toggle-focused-diff-enabled', + }, + }), + bool({ + key: 'features.gitCrossCloneCherryPick', default: true, absentFallback: false, runtime: 'live', runtimeFlag: 'gitCrossCloneCherryPickEnabled', + ui: { + group: 'review', order: 20, label: 'Cross-clone cherry-pick', badge: 'experimental', + hint: 'Adds a Git commit context-menu action that transfers one commit to another registered clone using patch export/apply. Enabled by default.', + testId: 'toggle-git-cross-clone-cherry-pick-enabled', + }, + }), + bool({ + key: 'features.sessionContextAttachments', default: false, runtime: 'live', runtimeFlag: 'sessionContextAttachmentsEnabled', + ui: { + group: 'review', order: 30, label: 'Session context attachments', badge: 'experimental', + hint: 'Allow dragging existing same-workspace chat sessions into chat composers as pointer-only context. Disabled by default.', + testId: 'toggle-session-context-attachments-enabled', + }, + }), + bool({ + key: 'features.commitChatLens', default: false, runtime: 'live', runtimeFlag: 'commitChatLensEnabled', + ui: { + group: 'review', order: 40, label: 'Review chat lens', badge: 'experimental', + hint: 'Open unpinned commit and pull-request review chat as a desktop bottom-right lens instead of the side panel or drawer. Disabled by default.', + testId: 'toggle-commit-chat-lens-enabled', + }, + }), + { + key: 'features.commitChatLensDormantMode', + value: { kind: 'enum', values: ['ghost', 'pill'], message: "features.commitChatLensDormantMode must be 'ghost' or 'pill'" }, + default: 'ghost', + runtime: 'live', + runtimeFlag: 'commitChatLensDormantMode', + ui: { + group: 'review', order: 50, label: 'Lens dormant mode', dependsOn: 'features.commitChatLens', + hint: 'How the lens recedes when your cursor leaves it. Ghost fades to near-transparent; Pill collapses to a compact status pill.', + control: { + type: 'select', + options: [ + { value: 'ghost', label: 'Ghost fade' }, + { value: 'pill', label: 'Collapse to pill' }, + ], + }, + testId: 'select-commit-chat-lens-dormant-mode', + }, + }, + bool({ key: 'features.autoAgentProviderRouting', default: false, runtime: 'restartRequired', runtimeFlag: 'autoAgentProviderRoutingEnabled' }), + + bool({ + key: 'workItems.hierarchy.enabled', default: true, absentFallback: false, runtime: 'live', runtimeFlag: 'workItemsHierarchyEnabled', + ui: { + group: 'workItems', order: 10, label: 'Work Items Hierarchy Board', + hint: 'Extends the Work Items tab into an Epic → Feature → PBI → Work Item / Bug hierarchy board. Enabled by default.', + testId: 'toggle-work-items-hierarchy-enabled', + }, + }), + bool({ + key: 'workItems.sync.enabled', default: false, runtime: 'live', runtimeFlag: 'workItemsSyncEnabled', + ui: { + group: 'workItems', order: 20, label: 'Remote Work Items', badge: 'preview', + hint: 'Enables remote provider integration for hierarchy mode: provider status, imports, save-to-provider updates, and background polling. Requires the hierarchy board and never stores provider tokens.', + testId: 'toggle-work-items-sync-enabled', + }, + }), + bool({ + key: 'workItems.aiAuthoring.enabled', default: false, runtime: 'live', runtimeFlag: 'workItemsAiAuthoringEnabled', + ui: { + group: 'workItems', order: 30, label: 'Work Items AI Authoring', badge: 'experimental', + hint: 'Adds AI-assisted work item creation and improvement to the Work Items tab. Disabled by default.', + testId: 'toggle-work-items-ai-authoring-enabled', + }, + }), + bool({ + key: 'workItems.workflow.enabled', default: false, runtime: 'live', runtimeFlag: 'workItemsWorkflowEnabled', + ui: { + group: 'workItems', order: 40, label: 'Work Items Workflow', badge: 'experimental', + hint: 'Enables the durable Work Items/Goals command-center workflow. Disabled by default.', + testId: 'toggle-work-items-workflow-enabled', + }, + }), + + bool({ + key: 'effortLevels.enabled', default: false, runtime: 'live', runtimeFlag: 'effortLevelsEnabled', + ui: { + group: 'aiModes', order: 40, label: 'Effort Tiers', badge: 'experimental', + hint: 'Replace the model picker + reasoning-effort pill in the chat composer with a single Low / Medium / High effort selector. Configure tier mappings per provider on the AI Provider page. Disabled by default.', + testId: 'toggle-effort-levels-enabled', + }, + }), +]; + +// ── derived views ───────────────────────────────────────────────────────────── + +/** All flat keys, in registry order. */ +export const ADMIN_SETTING_KEYS: readonly string[] = ADMIN_SETTING_DEFINITIONS.map(d => d.key); + +/** Dot-notation (namespaced) keys — tracked by the namespace registry. */ +export const NAMESPACED_ADMIN_SETTING_KEYS: readonly string[] = + ADMIN_SETTING_KEYS.filter(key => key.includes('.')); + +/** Look up a definition by flat key. */ +export function getAdminSettingDefinition(key: string): AdminSettingDefinition | undefined { + return ADMIN_SETTING_DEFINITIONS.find(d => d.key === key); +} + +/** Settings surfaced on the admin Features card, sorted by group order. */ +export function getFeatureCardSettings(group: FeatureGroupId): readonly AdminSettingDefinition[] { + return ADMIN_SETTING_DEFINITIONS + .filter(d => d.ui?.group === group) + .sort((a, b) => (a.ui!.order - b.ui!.order)); +} diff --git a/packages/coc/src/config/namespace-registry.ts b/packages/coc/src/config/namespace-registry.ts index 9268d16ec..2cc4d13d3 100644 --- a/packages/coc/src/config/namespace-registry.ts +++ b/packages/coc/src/config/namespace-registry.ts @@ -1,4 +1,10 @@ import type { AutoProviderRoutingConfig, CLIConfig, ConfigFieldSource, ResolvedCLIConfig } from '../config'; +import { + ADMIN_SETTING_DEFINITIONS, + NAMESPACED_ADMIN_SETTING_KEYS, + getConfigValueAtPath, + setConfigValueAtPath, +} from './admin-setting-definitions'; type ConfigObject = Record; type ResolvedAutoProviderRoutingConfig = ResolvedCLIConfig['agentProviderRouting']['auto']; @@ -50,62 +56,44 @@ export type ResolvedConfigNamespaceValues = Pick< | 'effortLevels' >; -const CHAT_FOLLOW_UP_SOURCE_KEYS = [ - 'chat.followUpSuggestions.enabled', - 'chat.followUpSuggestions.count', -] as const; - -const CHAT_ASK_USER_SOURCE_KEYS = [ - 'chat.askUser.enabled', -] as const; +// ── hand-tracked source keys (fields NOT covered by the admin setting registry) ── -const SERVE_SOURCE_KEYS = [ +const SERVE_BASE_SOURCE_KEYS = [ 'serve.port', 'serve.host', 'serve.dataDir', 'serve.theme', - 'serve.serverName', ] as const; -const TERMINAL_SOURCE_KEYS = ['terminal.enabled'] as const; -const NOTES_SOURCE_KEYS = ['notes.enabled'] as const; -const MY_WORK_SOURCE_KEYS = ['myWork.enabled'] as const; -const MY_LIFE_SOURCE_KEYS = ['myLife.enabled'] as const; -const SCRATCHPAD_SOURCE_KEYS = ['scratchpad.enabled', 'scratchpad.layout'] as const; -const WORKFLOWS_SOURCE_KEYS = ['workflows.enabled'] as const; -const PULL_REQUESTS_SOURCE_KEYS = [ - 'pullRequests.enabled', - 'pullRequests.suggestions', - 'pullRequests.autoClassifyTeam', -] as const; -const SERVERS_SOURCE_KEYS = ['servers.enabled'] as const; -const RALPH_SOURCE_KEYS = ['ralph.enabled'] as const; -const RALPH_FINAL_CHECK_SOURCE_KEYS = ['ralph.finalCheck.maxGapFixLoops'] as const; -const FOR_EACH_SOURCE_KEYS = ['forEach.enabled'] as const; -const MAP_REDUCE_SOURCE_KEYS = ['mapReduce.enabled'] as const; -const VIM_NAVIGATION_SOURCE_KEYS = ['vimNavigation.enabled'] as const; -const LOOPS_SOURCE_KEYS = ['loops.enabled'] as const; -const MCP_OAUTH_SOURCE_KEYS = ['mcpOauth.enabled'] as const; -const MCP_OAUTH_AUTO_REFRESH_SOURCE_KEYS = ['mcpOauth.autoRefresh.enabled'] as const; -const EXCALIDRAW_SOURCE_KEYS = ['excalidraw.enabled'] as const; -const CONTAINER_DEFAULT_AGENT_SOURCE_KEYS = ['containerDefaultAgent.enabled'] as const; -const AGENT_PROVIDER_ROUTING_SOURCE_KEYS = ['agentProviderRouting.auto'] as const; -const CODEX_SOURCE_KEYS = ['codex.enabled'] as const; -const CLAUDE_SOURCE_KEYS = ['claude.enabled'] as const; -const FEATURES_SOURCE_KEYS = [ +const FEATURES_BASE_SOURCE_KEYS = [ 'features.autoMemoryPromotion', - 'features.focusedDiff', - 'features.gitCrossCloneCherryPick', - 'features.sessionContextAttachments', - 'features.commitChatLens', - 'features.commitChatLensDormantMode', - 'features.autoAgentProviderRouting', ] as const; -const WORK_ITEMS_HIERARCHY_SOURCE_KEYS = ['workItems.hierarchy.enabled'] as const; -const WORK_ITEMS_SYNC_SOURCE_KEYS = ['workItems.sync.enabled'] as const; -const WORK_ITEMS_AI_AUTHORING_SOURCE_KEYS = ['workItems.aiAuthoring.enabled'] as const; -const WORK_ITEMS_WORKFLOW_SOURCE_KEYS = ['workItems.workflow.enabled'] as const; -const EFFORT_LEVELS_SOURCE_KEYS = ['effortLevels.enabled'] as const; + +const MEMORY_PROMOTION_SOURCE_KEYS = [ + 'memoryPromotion.batchSize', + 'memoryPromotion.timeoutMs', + 'memoryPromotion.model', +] as const; + +const MEMORY_PROMOTION_AI_NORMALIZATION_SOURCE_KEYS = [ + 'memoryPromotion.aiNormalization.enabled', + 'memoryPromotion.aiNormalization.timeoutMs', + 'memoryPromotion.aiNormalization.model', +] as const; + +/** + * All namespaced (dot-notation) config keys with per-field source tracking: + * every namespaced admin setting plus the hand-tracked non-admin fields above. + */ +export const CONFIG_NAMESPACE_SOURCE_KEYS: readonly string[] = [ + ...NAMESPACED_ADMIN_SETTING_KEYS, + ...SERVE_BASE_SOURCE_KEYS, + ...FEATURES_BASE_SOURCE_KEYS, + ...MEMORY_PROMOTION_SOURCE_KEYS, + ...MEMORY_PROMOTION_AI_NORMALIZATION_SOURCE_KEYS, +]; + +const NAMESPACED_ADMIN_SETTING_KEY_SET = new Set(NAMESPACED_ADMIN_SETTING_KEYS); const DEFAULT_AUTO_PROVIDER_ROUTING: ResolvedAutoProviderRoutingConfig = { rules: [ @@ -131,53 +119,6 @@ const DEFAULT_AUTO_PROVIDER_ROUTING: ResolvedAutoProviderRoutingConfig = { fallbackProvider: 'copilot', }; -const MEMORY_PROMOTION_SOURCE_KEYS = [ - 'memoryPromotion.batchSize', - 'memoryPromotion.timeoutMs', - 'memoryPromotion.model', -] as const; - -const MEMORY_PROMOTION_AI_NORMALIZATION_SOURCE_KEYS = [ - 'memoryPromotion.aiNormalization.enabled', - 'memoryPromotion.aiNormalization.timeoutMs', - 'memoryPromotion.aiNormalization.model', -] as const; - -export const CONFIG_NAMESPACE_SOURCE_KEYS = [ - ...CHAT_FOLLOW_UP_SOURCE_KEYS, - ...CHAT_ASK_USER_SOURCE_KEYS, - ...SERVE_SOURCE_KEYS, - ...TERMINAL_SOURCE_KEYS, - ...NOTES_SOURCE_KEYS, - ...MY_WORK_SOURCE_KEYS, - ...MY_LIFE_SOURCE_KEYS, - ...SCRATCHPAD_SOURCE_KEYS, - ...WORKFLOWS_SOURCE_KEYS, - ...PULL_REQUESTS_SOURCE_KEYS, - ...SERVERS_SOURCE_KEYS, - ...RALPH_SOURCE_KEYS, - ...RALPH_FINAL_CHECK_SOURCE_KEYS, - ...FOR_EACH_SOURCE_KEYS, - ...MAP_REDUCE_SOURCE_KEYS, - ...VIM_NAVIGATION_SOURCE_KEYS, - ...LOOPS_SOURCE_KEYS, - ...MCP_OAUTH_SOURCE_KEYS, - ...MCP_OAUTH_AUTO_REFRESH_SOURCE_KEYS, - ...EXCALIDRAW_SOURCE_KEYS, - ...CONTAINER_DEFAULT_AGENT_SOURCE_KEYS, - ...AGENT_PROVIDER_ROUTING_SOURCE_KEYS, - ...CODEX_SOURCE_KEYS, - ...CLAUDE_SOURCE_KEYS, - ...FEATURES_SOURCE_KEYS, - ...MEMORY_PROMOTION_SOURCE_KEYS, - ...MEMORY_PROMOTION_AI_NORMALIZATION_SOURCE_KEYS, - ...WORK_ITEMS_HIERARCHY_SOURCE_KEYS, - ...WORK_ITEMS_SYNC_SOURCE_KEYS, - ...WORK_ITEMS_AI_AUTHORING_SOURCE_KEYS, - ...WORK_ITEMS_WORKFLOW_SOURCE_KEYS, - ...EFFORT_LEVELS_SOURCE_KEYS, -] as const; - const source = ( prefix: string, path: readonly string[], @@ -189,37 +130,21 @@ const source = ( }); /** - * Registry of namespaced CoC config sections. + * Registry of namespaced CoC config sections that need HAND-WRITTEN merge + * logic — sections that are not (or not fully) admin-editable, plus custom + * resolution like agentProviderRouting. * - * To add a namespaced config section, add one descriptor here with: - * - source descriptors for fields surfaced by getResolvedConfigWithSource() - * - merge logic for applying partial file config on top of resolved defaults + * Admin-editable leaves are merged GENERICALLY from the setting registry in + * admin-setting-definitions.ts (see mergeConfigNamespaces) — a new admin + * setting in an existing or new namespace needs NO entry here. * * Top-level scalar fields remain in config.ts. */ export function createConfigNamespaceRegistry(defaultBundledSkills: readonly string[]): readonly ConfigNamespaceDescriptor[] { return [ - { - name: 'chat', - sourceDescriptors: [ - source('chat.followUpSuggestions.', ['chat', 'followUpSuggestions'], CHAT_FOLLOW_UP_SOURCE_KEYS), - source('chat.askUser.', ['chat', 'askUser'], CHAT_ASK_USER_SOURCE_KEYS), - ], - merge: (base, override) => ({ - chat: { - followUpSuggestions: { - enabled: override?.chat?.followUpSuggestions?.enabled ?? base.chat.followUpSuggestions.enabled, - count: override?.chat?.followUpSuggestions?.count ?? base.chat.followUpSuggestions.count, - }, - askUser: { - enabled: override?.chat?.askUser?.enabled ?? base.chat.askUser.enabled, - }, - }, - }), - }, { name: 'serve', - sourceDescriptors: [source('serve.', ['serve'], SERVE_SOURCE_KEYS)], + sourceDescriptors: [source('serve.', ['serve'], SERVE_BASE_SOURCE_KEYS)], merge: (base, override) => ({ serve: { port: override?.serve?.port ?? base.serve?.port ?? 4000, @@ -262,152 +187,25 @@ export function createConfigNamespaceRegistry(defaultBundledSkills: readonly str sourceDescriptors: [], merge: (base, override) => ({ logging: override?.logging ?? base.logging }), }, - { - name: 'terminal', - sourceDescriptors: [source('terminal.', ['terminal'], TERMINAL_SOURCE_KEYS)], - merge: (base, override) => ({ terminal: { enabled: override?.terminal?.enabled ?? base.terminal?.enabled ?? true } }), - }, - { - name: 'notes', - sourceDescriptors: [source('notes.', ['notes'], NOTES_SOURCE_KEYS)], - merge: (base, override) => ({ notes: { enabled: override?.notes?.enabled ?? base.notes?.enabled ?? true } }), - }, - { - name: 'myWork', - sourceDescriptors: [source('myWork.', ['myWork'], MY_WORK_SOURCE_KEYS)], - merge: (base, override) => ({ myWork: { enabled: override?.myWork?.enabled ?? base.myWork?.enabled ?? false } }), - }, - { - name: 'myLife', - sourceDescriptors: [source('myLife.', ['myLife'], MY_LIFE_SOURCE_KEYS)], - merge: (base, override) => ({ myLife: { enabled: override?.myLife?.enabled ?? base.myLife?.enabled ?? false } }), - }, - { - name: 'scratchpad', - sourceDescriptors: [source('scratchpad.', ['scratchpad'], SCRATCHPAD_SOURCE_KEYS)], - merge: (base, override) => ({ - scratchpad: { - enabled: override?.scratchpad?.enabled ?? base.scratchpad?.enabled ?? false, - layout: override?.scratchpad?.layout ?? base.scratchpad?.layout ?? 'vertical', - }, - }), - }, - { - name: 'workflows', - sourceDescriptors: [source('workflows.', ['workflows'], WORKFLOWS_SOURCE_KEYS)], - merge: (base, override) => ({ workflows: { enabled: override?.workflows?.enabled ?? base.workflows?.enabled ?? false } }), - }, - { - name: 'pullRequests', - sourceDescriptors: [source('pullRequests.', ['pullRequests'], PULL_REQUESTS_SOURCE_KEYS)], - merge: (base, override) => ({ - pullRequests: { - enabled: override?.pullRequests?.enabled ?? base.pullRequests?.enabled ?? true, - suggestions: override?.pullRequests?.suggestions ?? base.pullRequests?.suggestions ?? false, - autoClassifyTeam: override?.pullRequests?.autoClassifyTeam ?? base.pullRequests?.autoClassifyTeam ?? false, - }, - }), - }, - { - name: 'servers', - sourceDescriptors: [source('servers.', ['servers'], SERVERS_SOURCE_KEYS)], - merge: (base, override) => ({ servers: { enabled: override?.servers?.enabled ?? base.servers?.enabled ?? true } }), - }, - { - name: 'ralph', - sourceDescriptors: [ - source('ralph.finalCheck.', ['ralph', 'finalCheck'], RALPH_FINAL_CHECK_SOURCE_KEYS), - source('ralph.', ['ralph'], RALPH_SOURCE_KEYS), - ], - merge: (base, override) => ({ - ralph: { - enabled: override?.ralph?.enabled ?? base.ralph?.enabled ?? false, - finalCheck: { - maxGapFixLoops: override?.ralph?.finalCheck?.maxGapFixLoops ?? base.ralph?.finalCheck?.maxGapFixLoops ?? 3, - }, - }, - }), - }, - { - name: 'forEach', - sourceDescriptors: [source('forEach.', ['forEach'], FOR_EACH_SOURCE_KEYS)], - merge: (base, override) => ({ forEach: { enabled: override?.forEach?.enabled ?? base.forEach?.enabled ?? false } }), - }, - { - name: 'mapReduce', - sourceDescriptors: [source('mapReduce.', ['mapReduce'], MAP_REDUCE_SOURCE_KEYS)], - merge: (base, override) => ({ mapReduce: { enabled: override?.mapReduce?.enabled ?? base.mapReduce?.enabled ?? false } }), - }, - { - name: 'vimNavigation', - sourceDescriptors: [source('vimNavigation.', ['vimNavigation'], VIM_NAVIGATION_SOURCE_KEYS)], - merge: (base, override) => ({ vimNavigation: { enabled: override?.vimNavigation?.enabled ?? base.vimNavigation?.enabled ?? false } }), - }, - { - name: 'loops', - sourceDescriptors: [source('loops.', ['loops'], LOOPS_SOURCE_KEYS)], - merge: (base, override) => ({ loops: { enabled: override?.loops?.enabled ?? base.loops?.enabled ?? false } }), - }, - { - name: 'mcpOauth', - sourceDescriptors: [ - source('mcpOauth.', ['mcpOauth'], MCP_OAUTH_SOURCE_KEYS), - source('mcpOauth.autoRefresh.', ['mcpOauth', 'autoRefresh'], MCP_OAUTH_AUTO_REFRESH_SOURCE_KEYS), - ], - merge: (base, override) => ({ - mcpOauth: { - enabled: override?.mcpOauth?.enabled ?? base.mcpOauth?.enabled ?? false, - autoRefresh: { - enabled: override?.mcpOauth?.autoRefresh?.enabled - ?? base.mcpOauth?.autoRefresh?.enabled - ?? false, - }, - }, - }), - }, - { - name: 'excalidraw', - sourceDescriptors: [source('excalidraw.', ['excalidraw'], EXCALIDRAW_SOURCE_KEYS)], - merge: (base, override) => ({ excalidraw: { enabled: override?.excalidraw?.enabled ?? base.excalidraw?.enabled ?? false } }), - }, - { - name: 'containerDefaultAgent', - sourceDescriptors: [source('containerDefaultAgent.', ['containerDefaultAgent'], CONTAINER_DEFAULT_AGENT_SOURCE_KEYS)], - merge: (base, override) => ({ containerDefaultAgent: { enabled: override?.containerDefaultAgent?.enabled ?? base.containerDefaultAgent?.enabled ?? false } }), - }, { name: 'agentProviderRouting', - sourceDescriptors: [source('agentProviderRouting.', ['agentProviderRouting'], AGENT_PROVIDER_ROUTING_SOURCE_KEYS)], + sourceDescriptors: [], merge: (base, override) => ({ agentProviderRouting: { auto: resolveAutoProviderRouting(base.agentProviderRouting?.auto, override?.agentProviderRouting?.auto), }, }), }, - { - name: 'codex', - sourceDescriptors: [source('codex.', ['codex'], CODEX_SOURCE_KEYS)], - merge: (base, override) => ({ codex: { enabled: override?.codex?.enabled ?? base.codex?.enabled ?? false } }), - }, - { - name: 'claude', - sourceDescriptors: [source('claude.', ['claude'], CLAUDE_SOURCE_KEYS)], - merge: (base, override) => ({ claude: { enabled: override?.claude?.enabled ?? base.claude?.enabled ?? false } }), - }, { name: 'features', - sourceDescriptors: [source('features.', ['features'], FEATURES_SOURCE_KEYS)], + sourceDescriptors: [source('features.', ['features'], FEATURES_BASE_SOURCE_KEYS)], merge: (base, override) => ({ + // Admin-editable features.* leaves are filled in by the generic + // registry merge pass; only file-only flags are merged here. features: { autoMemoryPromotion: override?.features?.autoMemoryPromotion ?? base.features?.autoMemoryPromotion ?? false, - focusedDiff: override?.features?.focusedDiff ?? base.features?.focusedDiff ?? false, gitCommitLookup: override?.features?.gitCommitLookup ?? base.features?.gitCommitLookup ?? false, - gitCrossCloneCherryPick: override?.features?.gitCrossCloneCherryPick ?? base.features?.gitCrossCloneCherryPick ?? true, - sessionContextAttachments: override?.features?.sessionContextAttachments ?? base.features?.sessionContextAttachments ?? false, - commitChatLens: override?.features?.commitChatLens ?? base.features?.commitChatLens ?? false, - commitChatLensDormantMode: override?.features?.commitChatLensDormantMode ?? base.features?.commitChatLensDormantMode ?? 'ghost', - autoAgentProviderRouting: override?.features?.autoAgentProviderRouting ?? base.features?.autoAgentProviderRouting ?? false, - }, + } as ResolvedCLIConfig['features'], }), }, { @@ -471,36 +269,6 @@ export function createConfigNamespaceRegistry(defaultBundledSkills: readonly str }, }), }, - { - name: 'workItems', - sourceDescriptors: [ - source('workItems.hierarchy.', ['workItems', 'hierarchy'], WORK_ITEMS_HIERARCHY_SOURCE_KEYS), - source('workItems.sync.', ['workItems', 'sync'], WORK_ITEMS_SYNC_SOURCE_KEYS), - source('workItems.aiAuthoring.', ['workItems', 'aiAuthoring'], WORK_ITEMS_AI_AUTHORING_SOURCE_KEYS), - source('workItems.workflow.', ['workItems', 'workflow'], WORK_ITEMS_WORKFLOW_SOURCE_KEYS), - ], - merge: (base, override) => ({ - workItems: { - hierarchy: { - enabled: override?.workItems?.hierarchy?.enabled ?? base.workItems?.hierarchy?.enabled ?? false, - }, - sync: { - enabled: override?.workItems?.sync?.enabled ?? base.workItems?.sync?.enabled ?? false, - }, - aiAuthoring: { - enabled: override?.workItems?.aiAuthoring?.enabled ?? base.workItems?.aiAuthoring?.enabled ?? false, - }, - workflow: { - enabled: override?.workItems?.workflow?.enabled ?? base.workItems?.workflow?.enabled ?? false, - }, - }, - }), - }, - { - name: 'effortLevels', - sourceDescriptors: [source('effortLevels.', ['effortLevels'], EFFORT_LEVELS_SOURCE_KEYS)], - merge: (base, override) => ({ effortLevels: { enabled: override?.effortLevels?.enabled ?? base.effortLevels?.enabled ?? false } }), - }, ]; } @@ -508,6 +276,30 @@ export const CONFIG_NAMESPACE_SOURCE_DESCRIPTORS = createConfigNamespaceRegistry .flatMap(descriptor => descriptor.sourceDescriptors) .sort((a, b) => b.prefix.length - a.prefix.length); +/** + * Generic merge for namespaced admin settings: for every dot-notation setting + * in the registry (except custom-merged ones), resolve + * `override ?? base ?? default` and write it into the result, creating + * namespace containers as needed. + */ +function applyAdminSettingLeaves( + result: ConfigObject, + base: ResolvedCLIConfig, + override: CLIConfig | undefined +): void { + for (const def of ADMIN_SETTING_DEFINITIONS) { + if (!def.key.includes('.') || def.customMerge) { + continue; + } + const value = getConfigValueAtPath(override, def.key) + ?? getConfigValueAtPath(base, def.key) + ?? def.default; + if (value !== undefined) { + setConfigValueAtPath(result, def.key, value); + } + } +} + export function mergeConfigNamespaces( base: ResolvedCLIConfig, override: CLIConfig | undefined, @@ -517,6 +309,7 @@ export function mergeConfigNamespaces( (merged, descriptor) => ({ ...merged, ...descriptor.merge(base, override) }), {} ); + applyAdminSettingLeaves(merged as ConfigObject, base, override); return merged as ResolvedConfigNamespaceValues; } @@ -525,6 +318,10 @@ export function getNamespaceFieldSource(key: string, fileConfig: CLIConfig | und return 'default'; } + if (NAMESPACED_ADMIN_SETTING_KEY_SET.has(key)) { + return getConfigValueAtPath(fileConfig, key) !== undefined ? 'file' : 'default'; + } + for (const descriptor of CONFIG_NAMESPACE_SOURCE_DESCRIPTORS) { if (key.startsWith(descriptor.prefix)) { const subKey = key.slice(descriptor.prefix.length); diff --git a/packages/coc/src/config/schema.ts b/packages/coc/src/config/schema.ts index cc826ae40..a5023ea96 100644 --- a/packages/coc/src/config/schema.ts +++ b/packages/coc/src/config/schema.ts @@ -1,14 +1,18 @@ /** * Zod schema for CLI configuration file. * - * Provides declarative validation with clear error messages - * instead of manual typeof checks. + * Admin-editable leaves are GENERATED from the unified setting registry in + * admin-setting-definitions.ts — adding a setting there extends this schema + * automatically. The hand-written base below only declares fields that are + * not admin-editable (queue, models, logging, monitoring, skills, …). */ import { z } from 'zod'; +import type { CLIConfig } from '../config'; +import { ADMIN_SETTING_DEFINITIONS, type AdminSettingValueSpec } from './admin-setting-definitions'; // ============================================================================ -// Logging sub-schemas +// Hand-written sub-schemas (non-admin-editable structures) // ============================================================================ const concreteAgentProviderEnum = z.enum(['copilot', 'codex', 'claude']); @@ -44,171 +48,171 @@ const autoProviderRoutingSchema = z.object({ }).passthrough(); /** - * Zod schema for CLI configuration file + * File schemas for registry settings with `kind: 'custom'` validation. + * Every custom setting must have an entry here (enforced at module load). */ -export const CLIConfigSchema = z.object({ - model: z.string().optional(), - parallel: z.number().int().positive().optional(), - output: z.enum(['table', 'json', 'csv', 'markdown']).optional(), - approvePermissions: z.boolean().optional(), - mcpConfig: z.string().optional(), - timeout: z.number().positive().optional(), - persist: z.boolean().optional(), - /** Show report_intent tool calls in conversation views (default: false) */ - showReportIntent: z.boolean().optional(), - /** How compact to render tool calls in conversation views: 0=full, 1=compact, 2=minimal, 3=whisper (default: 0) */ - toolCompactness: z.number().int().min(0).max(3).optional(), - /** Density of task cards in the activity tab: 'compact' (default) or 'dense' (single-line) */ - taskCardDensity: z.enum(['compact', 'dense']).optional(), - /** Absorb single-line messages between same-category tool groups (default: true) */ - groupSingleLineMessages: z.boolean().optional(), - chat: z.object({ - followUpSuggestions: z.object({ - enabled: z.boolean().optional(), - count: z.number().int().min(1).max(5).optional(), - }).passthrough().optional(), - askUser: z.object({ - enabled: z.boolean().optional(), - }).passthrough().optional(), - }).passthrough().optional(), - serve: z.object({ - port: z.number().int().positive().max(65535).optional(), - host: z.string().optional(), - dataDir: z.string().optional(), - theme: z.enum(['auto', 'light', 'dark']).optional(), - serverName: z.string().optional(), - }).passthrough().optional(), - queue: z.object({ - historyLimit: z.number().int().positive().optional(), - restartPolicy: z.enum(['fail', 'requeue', 'requeue-if-retriable']).optional(), - restartPickupDelayMs: z.number().int().min(0).optional(), - }).passthrough().optional(), - models: z.object({ - enabled: z.array(z.string()).optional(), - }).passthrough().optional(), - logging: loggingConfigSchema.optional(), - terminal: z.object({ - enabled: z.boolean().optional(), - }).passthrough().optional(), - notes: z.object({ - enabled: z.boolean().optional(), - }).passthrough().optional(), - myWork: z.object({ - enabled: z.boolean().optional(), - }).passthrough().optional(), - myLife: z.object({ - enabled: z.boolean().optional(), - }).passthrough().optional(), - scratchpad: z.object({ - enabled: z.boolean().optional(), - layout: z.enum(['horizontal', 'vertical']).optional(), - }).passthrough().optional(), - workflows: z.object({ - enabled: z.boolean().optional(), - }).passthrough().optional(), - pullRequests: z.object({ - enabled: z.boolean().optional(), - suggestions: z.boolean().optional(), - autoClassifyTeam: z.boolean().optional(), - }).passthrough().optional(), - servers: z.object({ - enabled: z.boolean().optional(), - }).passthrough().optional(), - vimNavigation: z.object({ - enabled: z.boolean().optional(), - }).passthrough().optional(), - excalidraw: z.object({ - enabled: z.boolean().optional(), - }).passthrough().optional(), - agentProviderRouting: z.object({ - auto: autoProviderRoutingSchema.optional(), - }).passthrough().optional(), - codex: z.object({ - enabled: z.boolean().optional(), - }).passthrough().optional(), - claude: z.object({ - enabled: z.boolean().optional(), - }).passthrough().optional(), - defaultProvider: concreteAgentProviderEnum.optional(), - ralph: z.object({ - enabled: z.boolean().optional(), - finalCheck: z.object({ - maxGapFixLoops: z.number().int().min(1).optional(), - }).passthrough().optional(), - }).passthrough().optional(), - forEach: z.object({ - enabled: z.boolean().optional(), - }).passthrough().optional(), - mapReduce: z.object({ - enabled: z.boolean().optional(), - }).passthrough().optional(), - loops: z.object({ - enabled: z.boolean().optional(), - }).passthrough().optional(), - mcpOauth: z.object({ - enabled: z.boolean().optional(), - autoRefresh: z.object({ - enabled: z.boolean().optional(), - }).passthrough().optional(), - }).passthrough().optional(), - features: z.object({ - autoMemoryPromotion: z.boolean().optional(), - focusedDiff: z.boolean().optional(), - gitCommitLookup: z.boolean().optional(), - gitCrossCloneCherryPick: z.boolean().optional(), - sessionContextAttachments: z.boolean().optional(), - commitChatLens: z.boolean().optional(), - commitChatLensDormantMode: z.enum(['ghost', 'pill']).optional(), - autoAgentProviderRouting: z.boolean().optional(), - }).passthrough().optional(), - memoryPromotion: z.object({ - batchSize: z.number().int().positive().optional(), - timeoutMs: z.number().int().positive().optional(), - model: z.string().optional(), - aiNormalization: z.object({ - enabled: z.boolean().optional(), - timeoutMs: z.number().int().positive().optional(), - model: z.string().optional(), - }).passthrough().optional(), - }).passthrough().optional(), - store: z.object({ - backend: z.enum(['file', 'sqlite']).optional(), - }).passthrough().optional(), - monitoring: z.object({ - heapCheck: z.object({ - enabled: z.boolean().optional(), - intervalMs: z.number().int().positive().optional(), - warnThreshold: z.number().min(0).max(100).optional(), - criticalThreshold: z.number().min(0).max(100).optional(), - }).passthrough().optional(), - }).passthrough().optional(), - skills: z.object({ - autoUpdate: z.boolean().optional(), - defaultSkills: z.array(z.string()).optional(), - }).passthrough().optional(), - workItems: z.object({ - hierarchy: z.object({ - enabled: z.boolean().optional(), - }).passthrough().optional(), - sync: z.object({ - enabled: z.boolean().optional(), - }).passthrough().optional(), - aiAuthoring: z.object({ - enabled: z.boolean().optional(), - }).passthrough().optional(), - workflow: z.object({ - enabled: z.boolean().optional(), - }).passthrough().optional(), - }).passthrough().optional(), - effortLevels: z.object({ - enabled: z.boolean().optional(), - }).passthrough().optional(), -}).passthrough(); +const CUSTOM_FILE_SCHEMAS: Record = { + 'agentProviderRouting.auto': autoProviderRoutingSchema, +}; + +// ============================================================================ +// Schema generation from the admin setting registry +// ============================================================================ + +type SchemaTree = { [key: string]: z.ZodTypeAny | SchemaTree }; + +function isZodType(value: z.ZodTypeAny | SchemaTree): value is z.ZodTypeAny { + return value instanceof z.ZodType; +} + +/** + * Map a registry value spec to its (loose) file-schema leaf. Admin-only + * constraints that would reject historically valid files (e.g. string length + * limits) are intentionally not applied here. + */ +function zodLeafForSpec(key: string, spec: AdminSettingValueSpec): z.ZodTypeAny { + switch (spec.kind) { + case 'boolean': + return z.boolean(); + case 'string': + return z.string(); + case 'enum': + return z.enum(spec.values as [string, ...string[]]); + case 'number': { + let leaf = z.number(); + if (spec.integer) leaf = leaf.int(); + if (spec.gt === 0) leaf = leaf.positive(); + else if (spec.gt !== undefined) leaf = leaf.gt(spec.gt); + if (spec.min !== undefined) leaf = leaf.min(spec.min); + if (spec.max !== undefined) leaf = leaf.max(spec.max); + return leaf; + } + case 'custom': { + const custom = CUSTOM_FILE_SCHEMAS[key]; + if (!custom) { + throw new Error(`No file schema registered for custom admin setting '${key}' — add it to CUSTOM_FILE_SCHEMAS in config/schema.ts`); + } + return custom; + } + } +} + +/** Insert a leaf schema into a nested tree at a dot-notation key. */ +function insertLeaf(tree: SchemaTree, key: string, leaf: z.ZodTypeAny): void { + const segments = key.split('.'); + let current = tree; + for (const segment of segments.slice(0, -1)) { + const next = current[segment]; + if (next === undefined) { + const created: SchemaTree = {}; + current[segment] = created; + current = created; + } else if (isZodType(next)) { + throw new Error(`Schema tree conflict at '${key}': '${segment}' is already a leaf`); + } else { + current = next; + } + } + current[segments[segments.length - 1]] = leaf; +} + +function buildAdminSettingsSchemaTree(): SchemaTree { + const tree: SchemaTree = {}; + for (const def of ADMIN_SETTING_DEFINITIONS) { + insertLeaf(tree, def.key, zodLeafForSpec(def.key, def.value)); + } + return tree; +} + +/** Deep-merge two schema trees; overlay leaves win over base leaves. */ +function mergeSchemaTrees(base: SchemaTree, overlay: SchemaTree): SchemaTree { + const merged: SchemaTree = { ...base }; + for (const [key, value] of Object.entries(overlay)) { + const existing = merged[key]; + if (existing !== undefined && !isZodType(existing) && !isZodType(value)) { + merged[key] = mergeSchemaTrees(existing, value); + } else { + merged[key] = value; + } + } + return merged; +} + +/** Convert a schema tree to a zod object: every field optional, objects passthrough. */ +function treeToZodObject(tree: SchemaTree): z.ZodTypeAny { + const shape: Record = {}; + for (const [key, value] of Object.entries(tree)) { + shape[key] = (isZodType(value) ? value : treeToZodObject(value)).optional(); + } + return z.object(shape).passthrough(); +} + +/** + * Hand-written base tree: config-file fields that are NOT admin-editable. + * Leaves here must not overlap with registry keys (the generated tree wins). + */ +const BASE_SCHEMA_TREE: SchemaTree = { + approvePermissions: z.boolean(), + mcpConfig: z.string(), + persist: z.boolean(), + serve: { + port: z.number().int().positive().max(65535), + host: z.string(), + dataDir: z.string(), + theme: z.enum(['auto', 'light', 'dark']), + }, + queue: { + historyLimit: z.number().int().positive(), + restartPolicy: z.enum(['fail', 'requeue', 'requeue-if-retriable']), + restartPickupDelayMs: z.number().int().min(0), + }, + models: { + enabled: z.array(z.string()), + }, + logging: loggingConfigSchema, + features: { + autoMemoryPromotion: z.boolean(), + gitCommitLookup: z.boolean(), + }, + memoryPromotion: { + batchSize: z.number().int().positive(), + timeoutMs: z.number().int().positive(), + model: z.string(), + aiNormalization: { + enabled: z.boolean(), + timeoutMs: z.number().int().positive(), + model: z.string(), + }, + }, + store: { + backend: z.enum(['file', 'sqlite']), + }, + monitoring: { + heapCheck: { + enabled: z.boolean(), + intervalMs: z.number().int().positive(), + warnThreshold: z.number().min(0).max(100), + criticalThreshold: z.number().min(0).max(100), + }, + }, + skills: { + autoUpdate: z.boolean(), + defaultSkills: z.array(z.string()), + }, +}; + +/** + * Zod schema for CLI configuration file. + * Base (non-admin) shape + generated admin-editable leaves. + */ +export const CLIConfigSchema = treeToZodObject( + mergeSchemaTrees(BASE_SCHEMA_TREE, buildAdminSettingsSchemaTree()) +) as unknown as z.ZodType; /** - * Inferred type from schema (should match CLIConfig) + * Parsed config type (should match CLIConfig) */ -export type CLIConfigFromSchema = z.infer; +export type CLIConfigFromSchema = CLIConfig; /** * Validate a config object using the Zod schema. diff --git a/packages/coc/src/server/admin/admin-config-fields.ts b/packages/coc/src/server/admin/admin-config-fields.ts index ef66ae012..7f1410404 100644 --- a/packages/coc/src/server/admin/admin-config-fields.ts +++ b/packages/coc/src/server/admin/admin-config-fields.ts @@ -1,402 +1,53 @@ -/** - * Admin Config Field Registry - * - * Single source of truth for editable admin config fields. - * Each entry defines the flat key, a validator, and an apply function. - * - * To add a new editable admin config field: - * 1. Add the field to CLIConfig / ResolvedCLIConfig in config.ts (if structural) - * 2. Add a default in DEFAULT_CONFIG in config.ts - * 3. Add schema validation in config/schema.ts - * 4. Add namespace tracking in config/namespace-registry.ts (for nested fields) - * 5. Add ONE entry here — the admin handler picks it up automatically - * 6. Update AdminResolvedConfig / AdminConfigUpdate in coc-client/src/contracts/admin.ts - * 7. Add UI in AdminPanel.tsx - */ - -import type { AutoProviderRoutingConfig, CLIConfig, ConcreteAgentProvider, DefaultAgentProvider } from '../../config'; - -/** Runtime behavior classification for admin-editable config fields. */ -export type AdminConfigFieldRuntime = 'live' | 'reloadable' | 'restartRequired'; - -export interface AdminConfigFieldSpec { - /** Flat key used in the PUT /api/admin/config request body, e.g. 'loops.enabled' */ - key: string; - /** Runtime behavior: 'live' (immediate), 'reloadable', or 'restartRequired' */ - runtime: AdminConfigFieldRuntime; - /** Return an error message string if invalid, undefined if valid */ - validate: (value: unknown) => string | undefined; - /** Write the (already-validated) value into the CLIConfig that will be persisted */ - apply: (config: CLIConfig, value: unknown) => void; -} - -// ── helpers ────────────────────────────────────────────────────────────────── - -const bool = (key: string, set: (cfg: CLIConfig, v: boolean) => void, runtime: AdminConfigFieldRuntime = 'live'): AdminConfigFieldSpec => ({ - key, - runtime, - validate: (v) => typeof v === 'boolean' ? undefined : `${key} must be a boolean`, - apply: (cfg, v) => set(cfg, v as boolean), -}); - -const VALID_OUTPUT_VALUES = ['table', 'json', 'csv', 'markdown'] as const; -const VALID_DEFAULT_PROVIDER_VALUES = ['copilot', 'codex', 'claude'] as const; -const VALID_CONCRETE_PROVIDER_VALUES = ['copilot', 'codex', 'claude'] as const; - -function isObject(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -function isConcreteProvider(value: unknown): value is ConcreteAgentProvider { - return typeof value === 'string' && (VALID_CONCRETE_PROVIDER_VALUES as readonly string[]).includes(value); -} - -function validatePercent(value: unknown, key: string): string | undefined { - return typeof value === 'number' && Number.isInteger(value) && value >= 0 && value <= 100 - ? undefined - : `${key} must be an integer between 0 and 100`; -} - -function validateAutoProviderRouting(value: unknown): string | undefined { - if (!isObject(value)) { - return 'agentProviderRouting.auto must be an object'; - } - const rules = value.rules; - if (rules !== undefined) { - if (!Array.isArray(rules)) { - return 'agentProviderRouting.auto.rules must be an array'; - } - for (const [index, rule] of rules.entries()) { - if (!isObject(rule)) { - return `agentProviderRouting.auto.rules[${index}] must be an object`; - } - if (!isConcreteProvider(rule.provider)) { - return `agentProviderRouting.auto.rules[${index}].provider must be one of: ${VALID_CONCRETE_PROVIDER_VALUES.join(', ')}`; - } - if (rule.enabled !== undefined && typeof rule.enabled !== 'boolean') { - return `agentProviderRouting.auto.rules[${index}].enabled must be a boolean`; - } - if (rule.minimumRemainingPercent !== undefined) { - const err = validatePercent(rule.minimumRemainingPercent, `agentProviderRouting.auto.rules[${index}].minimumRemainingPercent`); - if (err) { return err; } - } - if (rule.weeklyGuard !== undefined) { - if (!isObject(rule.weeklyGuard)) { - return `agentProviderRouting.auto.rules[${index}].weeklyGuard must be an object`; - } - if (rule.weeklyGuard.enabled !== undefined && typeof rule.weeklyGuard.enabled !== 'boolean') { - return `agentProviderRouting.auto.rules[${index}].weeklyGuard.enabled must be a boolean`; - } - if (rule.weeklyGuard.minimumRemainingPercent !== undefined) { - const err = validatePercent(rule.weeklyGuard.minimumRemainingPercent, `agentProviderRouting.auto.rules[${index}].weeklyGuard.minimumRemainingPercent`); - if (err) { return err; } - } - } - } - } - if (value.fallbackProvider !== undefined && !isConcreteProvider(value.fallbackProvider)) { - return `agentProviderRouting.auto.fallbackProvider must be one of: ${VALID_CONCRETE_PROVIDER_VALUES.join(', ')}`; - } - return undefined; -} - -// ── registry ───────────────────────────────────────────────────────────────── - -/** - * All admin-editable config fields. - * The admin handler derives editableKeys, validation, and merge entirely from this list. - */ -export const ADMIN_CONFIG_FIELDS: readonly AdminConfigFieldSpec[] = [ - // ── AI execution ────────────────────────────────────────────────────────── - { - key: 'model', - runtime: 'live', - validate: (v) => typeof v === 'string' && v.length > 0 ? undefined : 'model must be a non-empty string', - apply: (cfg, v) => { cfg.model = v as string; }, - }, - { - key: 'parallel', - runtime: 'live', - validate: (v) => typeof v === 'number' && v > 0 ? undefined : 'parallel must be a number greater than 0', - apply: (cfg, v) => { cfg.parallel = v as number; }, - }, - { - key: 'timeout', - runtime: 'live', - validate: (v) => v === null || (typeof v === 'number' && v > 0) - ? undefined - : 'timeout must be a number greater than 0, or null to clear', - apply: (cfg, v) => { - if (v === null) { delete cfg.timeout; } else { cfg.timeout = v as number; } - }, - }, - { - key: 'output', - runtime: 'live', - validate: (v) => typeof v === 'string' && (VALID_OUTPUT_VALUES as readonly string[]).includes(v) - ? undefined - : `output must be one of: ${VALID_OUTPUT_VALUES.join(', ')}`, - apply: (cfg, v) => { cfg.output = v as CLIConfig['output']; }, - }, - - // ── display / UI ────────────────────────────────────────────────────────── - bool('showReportIntent', (cfg, v) => { cfg.showReportIntent = v; }), - { - key: 'toolCompactness', - runtime: 'live', - validate: (v) => - typeof v === 'number' && Number.isInteger(v) && v >= 0 && v <= 3 - ? undefined - : 'toolCompactness must be 0, 1, 2, or 3', - apply: (cfg, v) => { cfg.toolCompactness = v as CLIConfig['toolCompactness']; }, - }, - { - key: 'taskCardDensity', - runtime: 'live', - validate: (v) => v === 'compact' || v === 'dense' - ? undefined - : 'taskCardDensity must be "compact" or "dense"', - apply: (cfg, v) => { cfg.taskCardDensity = v as CLIConfig['taskCardDensity']; }, - }, - bool('groupSingleLineMessages', (cfg, v) => { cfg.groupSingleLineMessages = v; }), - - // ── serve ───────────────────────────────────────────────────────────────── - { - key: 'serve.serverName', - runtime: 'live', - validate: (v) => v === null || v === undefined || (typeof v === 'string' && v.length <= 64) - ? undefined - : 'serve.serverName must be a string of at most 64 characters, or null to clear', - apply: (cfg, v) => { - if (v === null || v === '') { - if (cfg.serve) { delete cfg.serve.serverName; } - } else { - if (!cfg.serve) { cfg.serve = {}; } - cfg.serve.serverName = v as string; - } - }, - }, - - // ── chat ───────────────────────────────────────────────────────────────── - bool('chat.followUpSuggestions.enabled', (cfg, v) => { - if (!cfg.chat) { cfg.chat = {}; } - if (!cfg.chat.followUpSuggestions) { cfg.chat.followUpSuggestions = {}; } - cfg.chat.followUpSuggestions.enabled = v; - }), - { - key: 'chat.followUpSuggestions.count', - runtime: 'live', - validate: (v) => - typeof v === 'number' && Number.isInteger(v) && v >= 1 && v <= 5 - ? undefined - : 'chat.followUpSuggestions.count must be an integer between 1 and 5', - apply: (cfg, v) => { - if (!cfg.chat) { cfg.chat = {}; } - if (!cfg.chat.followUpSuggestions) { cfg.chat.followUpSuggestions = {}; } - cfg.chat.followUpSuggestions.count = v as number; - }, - }, - bool('chat.askUser.enabled', (cfg, v) => { - if (!cfg.chat) { cfg.chat = {}; } - if (!cfg.chat.askUser) { cfg.chat.askUser = {}; } - cfg.chat.askUser.enabled = v; - }), - - // ── feature flags ───────────────────────────────────────────────────────── - bool('terminal.enabled', (cfg, v) => { - if (!cfg.terminal) { cfg.terminal = {}; } - cfg.terminal.enabled = v; - }, 'restartRequired'), - bool('notes.enabled', (cfg, v) => { - if (!cfg.notes) { cfg.notes = {}; } - cfg.notes.enabled = v; - }), - bool('myWork.enabled', (cfg, v) => { - if (!cfg.myWork) { cfg.myWork = {}; } - cfg.myWork.enabled = v; - }), - bool('myLife.enabled', (cfg, v) => { - if (!cfg.myLife) { cfg.myLife = {}; } - cfg.myLife.enabled = v; - }), - bool('scratchpad.enabled', (cfg, v) => { - if (!cfg.scratchpad) { cfg.scratchpad = {}; } - cfg.scratchpad.enabled = v; - }), - { - key: 'scratchpad.layout', - runtime: 'live', - validate: (v) => v === 'horizontal' || v === 'vertical' - ? undefined - : 'scratchpad.layout must be "horizontal" or "vertical"', - apply: (cfg, v) => { - if (!cfg.scratchpad) { cfg.scratchpad = {}; } - cfg.scratchpad.layout = v as 'horizontal' | 'vertical'; - }, - }, - bool('workflows.enabled', (cfg, v) => { - if (!cfg.workflows) { cfg.workflows = {}; } - cfg.workflows.enabled = v; - }), - bool('pullRequests.enabled', (cfg, v) => { - if (!cfg.pullRequests) { cfg.pullRequests = {}; } - cfg.pullRequests.enabled = v; - }), - bool('pullRequests.suggestions', (cfg, v) => { - if (!cfg.pullRequests) { cfg.pullRequests = {}; } - cfg.pullRequests.suggestions = v; - }), - bool('pullRequests.autoClassifyTeam', (cfg, v) => { - if (!cfg.pullRequests) { cfg.pullRequests = {}; } - cfg.pullRequests.autoClassifyTeam = v; - }), - bool('servers.enabled', (cfg, v) => { - if (!cfg.servers) { cfg.servers = {}; } - cfg.servers.enabled = v; - }), - bool('ralph.enabled', (cfg, v) => { - if (!cfg.ralph) { cfg.ralph = {}; } - cfg.ralph.enabled = v; - }), - bool('forEach.enabled', (cfg, v) => { - if (!cfg.forEach) { cfg.forEach = {}; } - cfg.forEach.enabled = v; - }), - bool('mapReduce.enabled', (cfg, v) => { - if (!cfg.mapReduce) { cfg.mapReduce = {}; } - cfg.mapReduce.enabled = v; - }), - { - key: 'ralph.finalCheck.maxGapFixLoops', - runtime: 'live' as AdminConfigFieldRuntime, - validate: (v) => typeof v === 'number' && Number.isInteger(v) && v >= 1 - ? undefined - : 'ralph.finalCheck.maxGapFixLoops must be a positive integer (≥ 1)', - apply: (cfg, v) => { - if (!cfg.ralph) { cfg.ralph = {}; } - if (!cfg.ralph.finalCheck) { cfg.ralph.finalCheck = {}; } - cfg.ralph.finalCheck.maxGapFixLoops = v as number; - }, - }, - bool('vimNavigation.enabled', (cfg, v) => { - if (!cfg.vimNavigation) { cfg.vimNavigation = {}; } - cfg.vimNavigation.enabled = v; - }), - bool('loops.enabled', (cfg, v) => { - if (!cfg.loops) { cfg.loops = {}; } - cfg.loops.enabled = v; - }, 'restartRequired'), - bool('excalidraw.enabled', (cfg, v) => { - if (!cfg.excalidraw) { cfg.excalidraw = {}; } - cfg.excalidraw.enabled = v; - }), - bool('mcpOauth.enabled', (cfg, v) => { - if (!cfg.mcpOauth) { cfg.mcpOauth = {}; } - cfg.mcpOauth.enabled = v; - }, 'restartRequired'), - bool('mcpOauth.autoRefresh.enabled', (cfg, v) => { - if (!cfg.mcpOauth) { cfg.mcpOauth = {}; } - if (!cfg.mcpOauth.autoRefresh) { cfg.mcpOauth.autoRefresh = {}; } - cfg.mcpOauth.autoRefresh.enabled = v; - }, 'restartRequired'), - bool('containerDefaultAgent.enabled', (cfg, v) => { - if (!cfg.containerDefaultAgent) { cfg.containerDefaultAgent = {}; } - cfg.containerDefaultAgent.enabled = v; - }), - bool('codex.enabled', (cfg, v) => { - if (!cfg.codex) { cfg.codex = {}; } - cfg.codex.enabled = v; - }), - bool('claude.enabled', (cfg, v) => { - if (!cfg.claude) { cfg.claude = {}; } - cfg.claude.enabled = v; - }), - { - key: 'defaultProvider', - runtime: 'restartRequired', - validate: (v) => typeof v === 'string' && (VALID_DEFAULT_PROVIDER_VALUES as readonly string[]).includes(v) - ? undefined - : 'defaultProvider must be "copilot", "codex", or "claude"', - apply: (cfg, v) => { cfg.defaultProvider = v as DefaultAgentProvider; }, - }, - { - key: 'agentProviderRouting.auto', - runtime: 'restartRequired', - validate: validateAutoProviderRouting, - apply: (cfg, v) => { - if (!cfg.agentProviderRouting) { cfg.agentProviderRouting = {}; } - cfg.agentProviderRouting.auto = v as AutoProviderRoutingConfig; - }, - }, - - bool('features.focusedDiff', (cfg, v) => { - if (!cfg.features) { cfg.features = {}; } - cfg.features.focusedDiff = v; - }), - bool('features.gitCrossCloneCherryPick', (cfg, v) => { - if (!cfg.features) { cfg.features = {}; } - cfg.features.gitCrossCloneCherryPick = v; - }), - bool('features.sessionContextAttachments', (cfg, v) => { - if (!cfg.features) { cfg.features = {}; } - cfg.features.sessionContextAttachments = v; - }), - bool('features.commitChatLens', (cfg, v) => { - if (!cfg.features) { cfg.features = {}; } - cfg.features.commitChatLens = v; - }), - { - key: 'features.commitChatLensDormantMode', - runtime: 'live' as AdminConfigFieldRuntime, - validate: (v) => (v === 'ghost' || v === 'pill') ? undefined : `features.commitChatLensDormantMode must be 'ghost' or 'pill'`, - apply: (cfg, v) => { - if (!cfg.features) { cfg.features = {}; } - cfg.features.commitChatLensDormantMode = v as 'ghost' | 'pill'; - }, - }, - bool('features.autoAgentProviderRouting', (cfg, v) => { - if (!cfg.features) { cfg.features = {}; } - cfg.features.autoAgentProviderRouting = v; - }, 'restartRequired'), - - bool('workItems.hierarchy.enabled', (cfg, v) => { - if (!cfg.workItems) { cfg.workItems = {}; } - if (!cfg.workItems.hierarchy) { cfg.workItems.hierarchy = {}; } - cfg.workItems.hierarchy.enabled = v; - }), - - bool('workItems.sync.enabled', (cfg, v) => { - if (!cfg.workItems) { cfg.workItems = {}; } - if (!cfg.workItems.sync) { cfg.workItems.sync = {}; } - cfg.workItems.sync.enabled = v; - }), - - bool('workItems.aiAuthoring.enabled', (cfg, v) => { - if (!cfg.workItems) { cfg.workItems = {}; } - if (!cfg.workItems.aiAuthoring) { cfg.workItems.aiAuthoring = {}; } - cfg.workItems.aiAuthoring.enabled = v; - }), +/** + * Admin Config Field Registry — derived from the unified setting definitions. + * + * The single source of truth for admin-editable settings lives in + * `config/admin-setting-definitions.ts`. This module derives the + * validate/apply field specs consumed by the PUT /api/admin/config handler + * and the RuntimeConfigService. Do not add fields here — add ONE definition + * entry in admin-setting-definitions.ts instead. + */ - bool('workItems.workflow.enabled', (cfg, v) => { - if (!cfg.workItems) { cfg.workItems = {}; } - if (!cfg.workItems.workflow) { cfg.workItems.workflow = {}; } - cfg.workItems.workflow.enabled = v; - }), +import type { CLIConfig } from '../../config'; +import { + ADMIN_SETTING_DEFINITIONS, + applyAdminSettingValue, + validateAdminSettingValue, + type AdminConfigFieldRuntime, +} from '../../config/admin-setting-definitions'; - bool('effortLevels.enabled', (cfg, v) => { - if (!cfg.effortLevels) { cfg.effortLevels = {}; } - cfg.effortLevels.enabled = v; - }), -]; - -/** Flat keys accepted by PUT /api/admin/config — derived from the registry. */ -export const ADMIN_EDITABLE_KEYS: readonly string[] = ADMIN_CONFIG_FIELDS.map(f => f.key); - -/** Build a key→metadata map for API responses. */ -export function getAdminFieldMetadata(): Record { - const meta: Record = {}; - for (const field of ADMIN_CONFIG_FIELDS) { - meta[field.key] = { runtime: field.runtime }; - } - return meta; -} +export type { AdminConfigFieldRuntime }; + +export interface AdminConfigFieldSpec { + /** Flat key used in the PUT /api/admin/config request body, e.g. 'loops.enabled' */ + key: string; + /** Runtime behavior: 'live' (immediate), 'reloadable', or 'restartRequired' */ + runtime: AdminConfigFieldRuntime; + /** Return an error message string if invalid, undefined if valid */ + validate: (value: unknown) => string | undefined; + /** Write the (already-validated) value into the CLIConfig that will be persisted */ + apply: (config: CLIConfig, value: unknown) => void; +} + +/** + * All admin-editable config fields. + * The admin handler derives editableKeys, validation, and merge entirely from this list. + */ +export const ADMIN_CONFIG_FIELDS: readonly AdminConfigFieldSpec[] = ADMIN_SETTING_DEFINITIONS.map(def => ({ + key: def.key, + runtime: def.runtime, + validate: (value: unknown) => validateAdminSettingValue(def, value), + apply: (config: CLIConfig, value: unknown) => applyAdminSettingValue(config, def, value), +})); + +/** Flat keys accepted by PUT /api/admin/config — derived from the registry. */ +export const ADMIN_EDITABLE_KEYS: readonly string[] = ADMIN_CONFIG_FIELDS.map(f => f.key); + +/** Build a key→metadata map for API responses. */ +export function getAdminFieldMetadata(): Record { + const meta: Record = {}; + for (const field of ADMIN_CONFIG_FIELDS) { + meta[field.key] = { runtime: field.runtime }; + } + return meta; +} diff --git a/packages/coc/src/server/config/runtime-config-handler.ts b/packages/coc/src/server/config/runtime-config-handler.ts index 33c65888c..797ab25af 100644 --- a/packages/coc/src/server/config/runtime-config-handler.ts +++ b/packages/coc/src/server/config/runtime-config-handler.ts @@ -11,6 +11,20 @@ import type { RuntimeConfigService } from '../../config/runtime-config-service'; import { sendJson } from '../shared/router'; import type { RuntimeDashboardConfig } from '@plusplusoneplusplus/coc-client'; import { shortenHostname } from '../core/hostname-utils'; +import { buildRuntimeFeatureFlags } from '../../config/admin-setting-definitions'; +import type { ResolvedCLIConfig } from '../../config'; + +/** + * Build the dashboard feature-flag map for a (possibly partial) config. + * Registry-driven: every admin setting with a `runtimeFlag` is included + * automatically. Flags not backed by an admin setting are added by hand here. + */ +export function buildRuntimeFeatures(config: Partial): RuntimeDashboardConfig['features'] { + return { + ...buildRuntimeFeatureFlags(config), + gitCommitLookupEnabled: config.features?.gitCommitLookup ?? false, + } as RuntimeDashboardConfig['features']; +} export interface RuntimeConfigRouteOptions { runtimeConfigService: RuntimeConfigService; @@ -29,42 +43,7 @@ export function buildRuntimeDashboardConfig( const config = runtimeConfigService.config; return { revision: runtimeConfigService.revision, - features: { - terminalEnabled: config.terminal?.enabled ?? true, - notesEnabled: config.notes?.enabled ?? true, - myWorkEnabled: config.myWork?.enabled ?? false, - myLifeEnabled: config.myLife?.enabled ?? false, - scratchpadEnabled: config.scratchpad?.enabled ?? false, - scratchpadLayout: config.scratchpad?.layout ?? 'horizontal', - workflowsEnabled: config.workflows?.enabled ?? false, - pullRequestsEnabled: config.pullRequests?.enabled ?? false, - pullRequestsSuggestionsEnabled: config.pullRequests?.suggestions ?? false, - pullRequestsAutoClassifyTeamEnabled: config.pullRequests?.autoClassifyTeam ?? false, - serversEnabled: config.servers?.enabled ?? false, - ralphEnabled: config.ralph?.enabled ?? false, - forEachEnabled: config.forEach?.enabled ?? false, - mapReduceEnabled: config.mapReduce?.enabled ?? false, - vimNavigationEnabled: config.vimNavigation?.enabled ?? false, - loopsEnabled: config.loops?.enabled ?? false, - excalidrawEnabled: config.excalidraw?.enabled ?? false, - mcpOauthEnabled: config.mcpOauth?.enabled ?? false, - focusedDiffEnabled: config.features?.focusedDiff ?? false, - containerDefaultAgentEnabled: config.containerDefaultAgent?.enabled ?? false, - codexEnabled: config.codex?.enabled ?? false, - claudeEnabled: config.claude?.enabled ?? false, - defaultProvider: config.defaultProvider ?? 'copilot', - autoAgentProviderRoutingEnabled: config.features?.autoAgentProviderRouting ?? false, - workItemsHierarchyEnabled: config.workItems?.hierarchy?.enabled ?? false, - workItemsSyncEnabled: config.workItems?.sync?.enabled ?? false, - workItemsAiAuthoringEnabled: config.workItems?.aiAuthoring?.enabled ?? false, - workItemsWorkflowEnabled: config.workItems?.workflow?.enabled ?? false, - gitCommitLookupEnabled: config.features?.gitCommitLookup ?? false, - gitCrossCloneCherryPickEnabled: config.features?.gitCrossCloneCherryPick ?? false, - sessionContextAttachmentsEnabled: config.features?.sessionContextAttachments ?? false, - commitChatLensEnabled: config.features?.commitChatLens ?? false, - commitChatLensDormantMode: config.features?.commitChatLensDormantMode ?? 'ghost', - effortLevelsEnabled: config.effortLevels?.enabled ?? false, - }, + features: buildRuntimeFeatures(config), hostname: config.serve?.serverName || shortenHostname(hostname), bindAddress, }; diff --git a/packages/coc/src/server/index.ts b/packages/coc/src/server/index.ts index 47a2bc405..8ff48bc50 100644 --- a/packages/coc/src/server/index.ts +++ b/packages/coc/src/server/index.ts @@ -39,6 +39,7 @@ import { createWebSocketInfrastructure } from './infrastructure/websocket-infras import { createWatcherInfrastructure } from './infrastructure/watcher-infrastructure'; import { createTerminalInfrastructure } from './infrastructure/terminal-infrastructure'; import { HeapMonitor } from './admin/heap-monitor'; +import { buildRuntimeFeatures } from './config/runtime-config-handler'; import { RuntimeConfigService } from '../config/runtime-config-service'; import { DEFAULT_AI_TIMEOUT_MS } from '@plusplusoneplusplus/forge'; import { autoUpdateBundledSkills, autoInstallDefaultSkills, autoInstallMyWorkSkills, DEFAULT_SKILLS_SETTINGS } from '@plusplusoneplusplus/forge'; @@ -606,32 +607,7 @@ export async function createExecutionServer(options: ExecutionServerOptions = {} return generateDashboardHtml({ enableWiki: true, hostname: liveConfig.serve?.serverName || shortenHostname(rawHostname), - terminalEnabled: liveConfig.terminal?.enabled ?? true, - notesEnabled: liveConfig.notes?.enabled ?? true, - myWorkEnabled: liveConfig.myWork?.enabled ?? false, - myLifeEnabled: liveConfig.myLife?.enabled ?? false, - scratchpadEnabled: liveConfig.scratchpad?.enabled ?? false, - scratchpadLayout: liveConfig.scratchpad?.layout ?? 'horizontal', - workflowsEnabled: liveConfig.workflows?.enabled ?? false, - pullRequestsEnabled: liveConfig.pullRequests?.enabled ?? false, - pullRequestsSuggestionsEnabled: liveConfig.pullRequests?.suggestions ?? false, - serversEnabled: liveConfig.servers?.enabled ?? false, - ralphEnabled: liveConfig.ralph?.enabled ?? false, - forEachEnabled: liveConfig.forEach?.enabled ?? false, - mapReduceEnabled: liveConfig.mapReduce?.enabled ?? false, - vimNavigationEnabled: liveConfig.vimNavigation?.enabled ?? false, - loopsEnabled: liveConfig.loops?.enabled ?? false, - excalidrawEnabled: liveConfig.excalidraw?.enabled ?? false, - mcpOauthEnabled: liveConfig.mcpOauth?.enabled ?? false, - focusedDiffEnabled: liveConfig.features?.focusedDiff ?? false, - sessionContextAttachmentsEnabled: liveConfig.features?.sessionContextAttachments ?? false, - commitChatLensEnabled: liveConfig.features?.commitChatLens ?? false, - commitChatLensDormantMode: liveConfig.features?.commitChatLensDormantMode ?? 'ghost', - autoAgentProviderRoutingEnabled: liveConfig.features?.autoAgentProviderRouting ?? false, - workItemsHierarchyEnabled: liveConfig.workItems?.hierarchy?.enabled ?? false, - workItemsSyncEnabled: liveConfig.workItems?.sync?.enabled ?? false, - workItemsAiAuthoringEnabled: liveConfig.workItems?.aiAuthoring?.enabled ?? false, - workItemsWorkflowEnabled: liveConfig.workItems?.workflow?.enabled ?? false, + features: buildRuntimeFeatures(liveConfig), bindAddress: host, }); }, diff --git a/packages/coc/src/server/spa/client/react/admin/AdminPanel.tsx b/packages/coc/src/server/spa/client/react/admin/AdminPanel.tsx index 6cfd36782..6d2357844 100644 --- a/packages/coc/src/server/spa/client/react/admin/AdminPanel.tsx +++ b/packages/coc/src/server/spa/client/react/admin/AdminPanel.tsx @@ -28,6 +28,13 @@ import { SettingsCard } from './SettingsCard'; import { isContainerMode, isServersEnabled } from '../utils/config'; import { AIProviderPage, normalizeAutoProviderRoutingConfig, type NormalizedAutoProviderRoutingConfig } from './AIProviderPage'; +import { + ADMIN_SETTING_DEFINITIONS, + FEATURE_CARD_GROUPS, + getFeatureCardSettings, + readAdminSettingValue, + type AdminSettingDefinition, +} from '../../../../../config/admin-setting-definitions'; const StorageSection = lazy(() => import('./StorageSection')); const AgentManagementPanel = lazy(() => import('../repos/AgentManagementPanel').then(m => ({ default: m.AgentManagementPanel }))); @@ -59,38 +66,30 @@ interface Stats { const VALID_OUTPUT_OPTIONS = ['table', 'json', 'csv', 'markdown'] as const; -type FeaturesSnapshot = { - terminal: boolean; - notes: boolean; - myWork: boolean; - myLife: boolean; - scratchpad: boolean; - scratchpadLayout: 'horizontal' | 'vertical'; - workflows: boolean; - pullRequests: boolean; - pullRequestsSuggestions: boolean; - pullRequestsAutoClassifyTeam: boolean; - servers: boolean; - ralph: boolean; - forEach: boolean; - mapReduce: boolean; - vimNavigation: boolean; - loops: boolean; - excalidraw: boolean; - mcpOauth: boolean; - mcpOauthAutoRefresh: boolean; - focusedDiff: boolean; - gitCrossCloneCherryPick: boolean; - sessionContextAttachments: boolean; - commitChatLens: boolean; - commitChatLensDormantMode: 'ghost' | 'pill'; - autoAgentProviderRouting: boolean; - workItemsHierarchy: boolean; - workItemsSync: boolean; - workItemsAiAuthoring: boolean; - workItemsWorkflow: boolean; - effortLevels: boolean; -}; +/** + * Features-card state: current and last-saved values keyed by flat config key + * (e.g. 'loops.enabled'). Rows, dirty state, and the save payload all derive + * from the admin setting registry — adding a setting there with `ui` metadata + * surfaces it here with no per-setting code. + */ +type FeatureValues = Record; + +const FEATURES_CARD_SETTINGS: readonly AdminSettingDefinition[] = + ADMIN_SETTING_DEFINITIONS.filter(def => def.ui !== undefined); + +function readFeatureValues(resolved: unknown): FeatureValues { + const values: FeatureValues = {}; + for (const def of FEATURES_CARD_SETTINGS) { + values[def.key] = readAdminSettingValue(def, resolved) as boolean | string; + } + return values; +} + +const FEATURE_BADGES: Record = { + restart: { className: 'ar-badge ar-badge-warning', label: 'Restart' }, + experimental: { className: 'ar-badge ar-badge-accent', label: 'Experimental' }, + preview: { className: 'ar-badge ar-badge-accent', label: 'Preview' }, +}; type DefaultProviderSnapshot = { provider: AdminDefaultProvider; @@ -364,36 +363,8 @@ export function AdminPanel() { const [serverName, setServerName] = useState(''); // Feature toggles - const [terminalEnabled, setTerminalEnabled] = useState(true); - const [notesEnabled, setNotesEnabled] = useState(true); - const [myWorkEnabled, setMyWorkEnabled] = useState(false); - const [myLifeEnabled, setMyLifeEnabled] = useState(false); - const [scratchpadEnabled, setScratchpadEnabled] = useState(false); - const [scratchpadLayout, setScratchpadLayout] = useState<'horizontal' | 'vertical'>('horizontal'); - const [workflowsEnabled, setWorkflowsEnabled] = useState(false); - const [pullRequestsEnabled, setPullRequestsEnabled] = useState(false); - const [pullRequestsSuggestionsEnabled, setPullRequestsSuggestionsEnabled] = useState(false); - const [pullRequestsAutoClassifyTeamEnabled, setPullRequestsAutoClassifyTeamEnabled] = useState(false); - const [serversEnabled, setServersEnabled] = useState(false); - const [ralphEnabled, setRalphEnabled] = useState(false); - const [forEachEnabled, setForEachEnabled] = useState(false); - const [mapReduceEnabled, setMapReduceEnabled] = useState(false); - const [vimNavigationEnabled, setVimNavigationEnabled] = useState(false); - const [loopsEnabled, setLoopsEnabled] = useState(false); - const [excalidrawEnabled, setExcalidrawEnabled] = useState(false); - const [mcpOauthEnabled, setMcpOauthEnabled] = useState(false); - const [mcpOauthAutoRefreshEnabled, setMcpOauthAutoRefreshEnabled] = useState(false); - const [focusedDiffEnabled, setFocusedDiffEnabled] = useState(false); - const [gitCrossCloneCherryPickEnabled, setGitCrossCloneCherryPickEnabled] = useState(false); - const [sessionContextAttachmentsEnabled, setSessionContextAttachmentsEnabled] = useState(false); - const [commitChatLensEnabled, setCommitChatLensEnabled] = useState(false); - const [commitChatLensDormantMode, setCommitChatLensDormantMode] = useState<'ghost' | 'pill'>('ghost'); + const [featureValues, setFeatureValues] = useState(() => readFeatureValues(undefined)); const [autoAgentProviderRoutingEnabled, setAutoAgentProviderRoutingEnabled] = useState(false); - const [workItemsHierarchyEnabled, setWorkItemsHierarchyEnabled] = useState(false); - const [workItemsSyncEnabled, setWorkItemsSyncEnabled] = useState(false); - const [workItemsAiAuthoringEnabled, setWorkItemsAiAuthoringEnabled] = useState(false); - const [workItemsWorkflowEnabled, setWorkItemsWorkflowEnabled] = useState(false); - const [effortLevelsEnabled, setEffortLevelsEnabled] = useState(false); const [codexEnabled, setCodexEnabled] = useState(false); const [claudeEnabled, setClaudeEnabled] = useState(false); const [defaultProvider, setDefaultProvider] = useState('copilot'); @@ -446,7 +417,7 @@ export function AdminPanel() { taskCardDensity: 'compact' as 'compact' | 'dense', historyGrouping: true, }); - const [featuresSnapshot, setFeaturesSnapshot] = useState({ terminal: true, notes: true, myWork: false, myLife: false, scratchpad: false, scratchpadLayout: 'horizontal', workflows: false, pullRequests: false, pullRequestsSuggestions: false, pullRequestsAutoClassifyTeam: false, servers: false, ralph: false, forEach: false, mapReduce: false, vimNavigation: false, loops: false, excalidraw: false, mcpOauth: false, mcpOauthAutoRefresh: false, focusedDiff: false, gitCrossCloneCherryPick: false, sessionContextAttachments: false, commitChatLens: false, commitChatLensDormantMode: 'ghost', autoAgentProviderRouting: false, workItemsHierarchy: false, workItemsSync: false, workItemsAiAuthoring: false, workItemsWorkflow: false, effortLevels: false }); + const [featuresSnapshot, setFeaturesSnapshot] = useState(() => readFeatureValues(undefined)); // Export const [exportStatus, setExportStatus] = useState(''); @@ -525,66 +496,11 @@ export function AdminPanel() { setHistoryGrouping(hg); setAppearanceSnapshot(prev => ({ ...prev, taskCardDensity: tcd, historyGrouping: hg })); setServerName(resolved.serve?.serverName ?? ''); - const te = resolved.terminal?.enabled ?? true; - const ne = resolved.notes?.enabled ?? false; - const mwe = resolved.myWork?.enabled ?? false; - const mle = resolved.myLife?.enabled ?? false; - setTerminalEnabled(te); - setNotesEnabled(ne); - setMyWorkEnabled(mwe); - setMyLifeEnabled(mle); - const se = resolved.scratchpad?.enabled ?? false; - setScratchpadEnabled(se); - const sl = (resolved.scratchpad?.layout === 'vertical' ? 'vertical' : 'horizontal') as 'horizontal' | 'vertical'; - setScratchpadLayout(sl); - const we = resolved.workflows?.enabled ?? false; - setWorkflowsEnabled(we); - const pre = resolved.pullRequests?.enabled ?? false; - setPullRequestsEnabled(pre); - const prse = resolved.pullRequests?.suggestions ?? false; - setPullRequestsSuggestionsEnabled(prse); - const pratce = resolved.pullRequests?.autoClassifyTeam ?? false; - setPullRequestsAutoClassifyTeamEnabled(pratce); - const svre = resolved.servers?.enabled ?? false; - setServersEnabled(svre); - const re = resolved.ralph?.enabled ?? false; - setRalphEnabled(re); - const fee = resolved.forEach?.enabled ?? false; - setForEachEnabled(fee); - const mre = resolved.mapReduce?.enabled ?? false; - setMapReduceEnabled(mre); - const vne = resolved.vimNavigation?.enabled ?? false; - setVimNavigationEnabled(vne); - const loe = resolved.loops?.enabled ?? false; - setLoopsEnabled(loe); - const exe = resolved.excalidraw?.enabled ?? false; - setExcalidrawEnabled(exe); - const moae = resolved.mcpOauth?.enabled ?? false; - setMcpOauthEnabled(moae); - const moare = resolved.mcpOauth?.autoRefresh?.enabled ?? false; - setMcpOauthAutoRefreshEnabled(moare); - const fde = resolved.features?.focusedDiff ?? false; - const gccpe = resolved.features?.gitCrossCloneCherryPick ?? false; - const scae = resolved.features?.sessionContextAttachments ?? false; - const ccle = resolved.features?.commitChatLens ?? false; - const ccldm = (resolved.features?.commitChatLensDormantMode === 'pill' ? 'pill' : 'ghost') as 'ghost' | 'pill'; + const loadedFeatures = readFeatureValues(resolved); + setFeatureValues(loadedFeatures); + setFeaturesSnapshot(loadedFeatures); const aapre = resolved.features?.autoAgentProviderRouting ?? false; - setGitCrossCloneCherryPickEnabled(gccpe); - setFocusedDiffEnabled(fde); - setSessionContextAttachmentsEnabled(scae); - setCommitChatLensEnabled(ccle); - setCommitChatLensDormantMode(ccldm); setAutoAgentProviderRoutingEnabled(aapre); - const wihe = resolved.workItems?.hierarchy?.enabled ?? false; - setWorkItemsHierarchyEnabled(wihe); - const wise = resolved.workItems?.sync?.enabled ?? false; - setWorkItemsSyncEnabled(wise); - const waae = resolved.workItems?.aiAuthoring?.enabled ?? false; - setWorkItemsAiAuthoringEnabled(waae); - const wiwfe = resolved.workItems?.workflow?.enabled ?? false; - setWorkItemsWorkflowEnabled(wiwfe); - const ele = resolved.effortLevels?.enabled ?? false; - setEffortLevelsEnabled(ele); const cxe = resolved.codex?.enabled ?? false; setCodexEnabled(cxe); const cle = resolved.claude?.enabled ?? false; @@ -593,7 +509,6 @@ export function AdminPanel() { const arc = normalizeAutoProviderRoutingConfig(resolved.agentProviderRouting?.auto); setDefaultProvider(dp); setAutoRoutingConfig(arc); - setFeaturesSnapshot({ terminal: te, notes: ne, myWork: mwe, myLife: mle, scratchpad: se, scratchpadLayout: sl, workflows: we, pullRequests: pre, pullRequestsSuggestions: prse, pullRequestsAutoClassifyTeam: pratce, servers: svre, ralph: re, forEach: fee, mapReduce: mre, vimNavigation: vne, loops: loe, excalidraw: exe, mcpOauth: moae, mcpOauthAutoRefresh: moare, focusedDiff: fde, gitCrossCloneCherryPick: gccpe, sessionContextAttachments: scae, commitChatLens: ccle, commitChatLensDormantMode: ccldm, autoAgentProviderRouting: aapre, workItemsHierarchy: wihe, workItemsSync: wise, workItemsAiAuthoring: waae, workItemsWorkflow: wiwfe, effortLevels: ele }); setAiExecSnapshot({ model: form.model, parallel: form.parallel, timeout: form.timeout, output: form.output }); setDefaultProviderSnapshot({ provider: dp, codexEnabled: cxe, claudeEnabled: cle, autoAgentProviderRouting: aapre, autoRoutingConfig: arc }); const sgr = resolved.sync?.gitRemote ?? ''; @@ -693,35 +608,7 @@ export function AdminPanel() { taskCardDensity !== appearanceSnapshot.taskCardDensity || historyGrouping !== appearanceSnapshot.historyGrouping; - const featuresDirty = terminalEnabled !== featuresSnapshot.terminal || - notesEnabled !== featuresSnapshot.notes || - myWorkEnabled !== featuresSnapshot.myWork || - myLifeEnabled !== featuresSnapshot.myLife || - scratchpadEnabled !== featuresSnapshot.scratchpad || - scratchpadLayout !== featuresSnapshot.scratchpadLayout || - workflowsEnabled !== featuresSnapshot.workflows || - pullRequestsEnabled !== featuresSnapshot.pullRequests || - pullRequestsSuggestionsEnabled !== featuresSnapshot.pullRequestsSuggestions || - pullRequestsAutoClassifyTeamEnabled !== featuresSnapshot.pullRequestsAutoClassifyTeam || - serversEnabled !== featuresSnapshot.servers || - ralphEnabled !== featuresSnapshot.ralph || - forEachEnabled !== featuresSnapshot.forEach || - mapReduceEnabled !== featuresSnapshot.mapReduce || - vimNavigationEnabled !== featuresSnapshot.vimNavigation || - loopsEnabled !== featuresSnapshot.loops || - excalidrawEnabled !== featuresSnapshot.excalidraw || - mcpOauthEnabled !== featuresSnapshot.mcpOauth || - mcpOauthAutoRefreshEnabled !== featuresSnapshot.mcpOauthAutoRefresh || - focusedDiffEnabled !== featuresSnapshot.focusedDiff || - gitCrossCloneCherryPickEnabled !== featuresSnapshot.gitCrossCloneCherryPick || - sessionContextAttachmentsEnabled !== featuresSnapshot.sessionContextAttachments || - commitChatLensEnabled !== featuresSnapshot.commitChatLens || - commitChatLensDormantMode !== featuresSnapshot.commitChatLensDormantMode || - workItemsHierarchyEnabled !== featuresSnapshot.workItemsHierarchy || - workItemsSyncEnabled !== featuresSnapshot.workItemsSync || - workItemsAiAuthoringEnabled !== featuresSnapshot.workItemsAiAuthoring || - workItemsWorkflowEnabled !== featuresSnapshot.workItemsWorkflow || - effortLevelsEnabled !== featuresSnapshot.effortLevels; + const featuresDirty = FEATURES_CARD_SETTINGS.some(def => featureValues[def.key] !== featuresSnapshot[def.key]); // ── AI & Execution card ── const handleSaveAiExec = useCallback(async () => { @@ -779,7 +666,6 @@ export function AdminPanel() { addToast('AI provider settings saved — restart required to apply changes', 'success'); setAutoRoutingConfig(normalizedAutoRouting); setDefaultProviderSnapshot({ provider: defaultProvider, codexEnabled, claudeEnabled, autoAgentProviderRouting: autoAgentProviderRoutingEnabled, autoRoutingConfig: normalizedAutoRouting }); - setFeaturesSnapshot(prev => ({ ...prev, autoAgentProviderRouting: autoAgentProviderRoutingEnabled })); } catch (err: unknown) { addToast(getSpaCocClientErrorMessage(err, 'Save failed'), 'error'); } finally { @@ -952,78 +838,19 @@ export function AdminPanel() { const handleSaveFeatures = useCallback(async () => { setFeaturesSaving(true); try { - const payload: Record = { - 'terminal.enabled': terminalEnabled, - 'notes.enabled': notesEnabled, - 'myWork.enabled': myWorkEnabled, - 'myLife.enabled': myLifeEnabled, - 'scratchpad.enabled': scratchpadEnabled, - 'scratchpad.layout': scratchpadLayout, - 'workflows.enabled': workflowsEnabled, - 'pullRequests.enabled': pullRequestsEnabled, - 'pullRequests.suggestions': pullRequestsSuggestionsEnabled, - 'pullRequests.autoClassifyTeam': pullRequestsAutoClassifyTeamEnabled, - 'servers.enabled': serversEnabled, - 'ralph.enabled': ralphEnabled, - 'forEach.enabled': forEachEnabled, - 'mapReduce.enabled': mapReduceEnabled, - 'vimNavigation.enabled': vimNavigationEnabled, - 'loops.enabled': loopsEnabled, - 'excalidraw.enabled': excalidrawEnabled, - 'mcpOauth.enabled': mcpOauthEnabled, - 'mcpOauth.autoRefresh.enabled': mcpOauthAutoRefreshEnabled, - 'features.focusedDiff': focusedDiffEnabled, - 'features.gitCrossCloneCherryPick': gitCrossCloneCherryPickEnabled, - 'features.sessionContextAttachments': sessionContextAttachmentsEnabled, - 'features.commitChatLens': commitChatLensEnabled, - 'features.commitChatLensDormantMode': commitChatLensDormantMode, - 'workItems.hierarchy.enabled': workItemsHierarchyEnabled, - 'workItems.sync.enabled': workItemsSyncEnabled, - 'workItems.aiAuthoring.enabled': workItemsAiAuthoringEnabled, - 'workItems.workflow.enabled': workItemsWorkflowEnabled, - 'effortLevels.enabled': effortLevelsEnabled, - }; - await getSpaCocClient().admin.updateConfig(payload); + await getSpaCocClient().admin.updateConfig({ ...featureValues }); addToast('Settings saved', 'success'); invalidateDisplaySettings(); - setFeaturesSnapshot(prev => ({ ...prev, terminal: terminalEnabled, notes: notesEnabled, myWork: myWorkEnabled, myLife: myLifeEnabled, scratchpad: scratchpadEnabled, scratchpadLayout: scratchpadLayout, workflows: workflowsEnabled, pullRequests: pullRequestsEnabled, pullRequestsSuggestions: pullRequestsSuggestionsEnabled, pullRequestsAutoClassifyTeam: pullRequestsAutoClassifyTeamEnabled, servers: serversEnabled, ralph: ralphEnabled, forEach: forEachEnabled, mapReduce: mapReduceEnabled, vimNavigation: vimNavigationEnabled, loops: loopsEnabled, excalidraw: excalidrawEnabled, mcpOauth: mcpOauthEnabled, mcpOauthAutoRefresh: mcpOauthAutoRefreshEnabled, focusedDiff: focusedDiffEnabled, gitCrossCloneCherryPick: gitCrossCloneCherryPickEnabled, sessionContextAttachments: sessionContextAttachmentsEnabled, commitChatLens: commitChatLensEnabled, commitChatLensDormantMode: commitChatLensDormantMode, autoAgentProviderRouting: prev.autoAgentProviderRouting, workItemsHierarchy: workItemsHierarchyEnabled, workItemsSync: workItemsSyncEnabled, workItemsAiAuthoring: workItemsAiAuthoringEnabled, workItemsWorkflow: workItemsWorkflowEnabled, effortLevels: effortLevelsEnabled })); - } catch (err: unknown) { + setFeaturesSnapshot({ ...featureValues }); + } catch (err: unknown) { addToast(getSpaCocClientErrorMessage(err, 'Save failed'), 'error'); } finally { setFeaturesSaving(false); } - }, [terminalEnabled, notesEnabled, myWorkEnabled, myLifeEnabled, scratchpadEnabled, scratchpadLayout, workflowsEnabled, pullRequestsEnabled, pullRequestsSuggestionsEnabled, pullRequestsAutoClassifyTeamEnabled, serversEnabled, ralphEnabled, forEachEnabled, mapReduceEnabled, vimNavigationEnabled, loopsEnabled, excalidrawEnabled, mcpOauthEnabled, mcpOauthAutoRefreshEnabled, focusedDiffEnabled, gitCrossCloneCherryPickEnabled, sessionContextAttachmentsEnabled, commitChatLensEnabled, commitChatLensDormantMode, workItemsHierarchyEnabled, workItemsSyncEnabled, workItemsAiAuthoringEnabled, workItemsWorkflowEnabled, effortLevelsEnabled, addToast]); + }, [featureValues, addToast]); const handleCancelFeatures = useCallback(() => { - setTerminalEnabled(featuresSnapshot.terminal); - setNotesEnabled(featuresSnapshot.notes); - setMyWorkEnabled(featuresSnapshot.myWork); - setMyLifeEnabled(featuresSnapshot.myLife); - setScratchpadEnabled(featuresSnapshot.scratchpad); - setScratchpadLayout(featuresSnapshot.scratchpadLayout); - setWorkflowsEnabled(featuresSnapshot.workflows); - setPullRequestsEnabled(featuresSnapshot.pullRequests); - setPullRequestsSuggestionsEnabled(featuresSnapshot.pullRequestsSuggestions); - setPullRequestsAutoClassifyTeamEnabled(featuresSnapshot.pullRequestsAutoClassifyTeam); - setServersEnabled(featuresSnapshot.servers); - setRalphEnabled(featuresSnapshot.ralph); - setForEachEnabled(featuresSnapshot.forEach); - setMapReduceEnabled(featuresSnapshot.mapReduce); - setVimNavigationEnabled(featuresSnapshot.vimNavigation); - setLoopsEnabled(featuresSnapshot.loops); - setExcalidrawEnabled(featuresSnapshot.excalidraw); - setMcpOauthEnabled(featuresSnapshot.mcpOauth); - setMcpOauthAutoRefreshEnabled(featuresSnapshot.mcpOauthAutoRefresh); - setFocusedDiffEnabled(featuresSnapshot.focusedDiff); - setGitCrossCloneCherryPickEnabled(featuresSnapshot.gitCrossCloneCherryPick); - setSessionContextAttachmentsEnabled(featuresSnapshot.sessionContextAttachments); - setCommitChatLensEnabled(featuresSnapshot.commitChatLens); - setCommitChatLensDormantMode(featuresSnapshot.commitChatLensDormantMode); - setWorkItemsHierarchyEnabled(featuresSnapshot.workItemsHierarchy); - setWorkItemsSyncEnabled(featuresSnapshot.workItemsSync); - setWorkItemsAiAuthoringEnabled(featuresSnapshot.workItemsAiAuthoring); - setWorkItemsWorkflowEnabled(featuresSnapshot.workItemsWorkflow); - setEffortLevelsEnabled(featuresSnapshot.effortLevels); + setFeatureValues({ ...featuresSnapshot }); }, [featuresSnapshot]); const handleSaveServerName = useCallback(async () => { @@ -1752,231 +1579,44 @@ export function AdminPanel() { onCancel={handleCancelFeatures} data-testid="settings-features" > - {/* ── Dashboard Modules ── */} -
-
Dashboard Modules
- - - - - - - - - - - - - - - - - {scratchpadEnabled && ( - - - - - )} -
- - {/* ── Development Tools ── */} -
-
Development Tools
- Terminal Restart} hint="Web terminal for shell access to the server machine. Toggling requires a server restart."> - - - - - - - - - - - - {pullRequestsEnabled && ( - <> - - - - - - - - - - )} - - - - -
- - {/* ── Work Items ── */} -
-
Work Items
- - - - - Remote Work Items Preview} - hint="Enables remote provider integration for hierarchy mode: provider status, imports, save-to-provider updates, and background polling. Requires the hierarchy board and never stores provider tokens." - > - - - - Work Items AI Authoring Experimental} - hint="Adds AI-assisted work item creation and improvement to the Work Items tab. Disabled by default." - > - - - - Work Items Workflow Experimental} - hint="Enables the durable Work Items/Goals command-center workflow. Disabled by default." - > - - - -
- - {/* ── AI Execution Modes ── */} -
-
AI Execution Modes
- Ralph Mode Experimental} - hint="Autonomous iterative coding loop — stateless agents with fresh context per iteration." - > - - - - For Each Mode Experimental} - hint="Generate a reviewed item plan from New Chat, then run each item as a separate child chat. Disabled by default." - > - - - - Map Reduce Mode Experimental} - hint="Generate a reviewed map plan from New Chat, run items in parallel, then reduce outputs into one result. Disabled by default." - > - - - - Effort Tiers Experimental} - hint="Replace the model picker + reasoning-effort pill in the chat composer with a single Low / Medium / High effort selector. Configure tier mappings per provider on the AI Provider page. Disabled by default." - > - - - -
- - {/* ── Code Review & Collaboration ── */} -
-
Code Review & Collaboration
- - - - - Cross-clone cherry-pick Experimental} - hint="Adds a Git commit context-menu action that transfers one commit to another registered clone using patch export/apply. Enabled by default." - > - - - - Session context attachments Experimental} - hint="Allow dragging existing same-workspace chat sessions into chat composers as pointer-only context. Disabled by default." - > - - - - Review chat lens Experimental} - hint="Open unpinned commit and pull-request review chat as a desktop bottom-right lens instead of the side panel or drawer. Disabled by default." - > - - - - {commitChatLensEnabled && ( - - - - - )} - - - - -
- - {/* ── Infrastructure ── */} -
-
Infrastructure
- Loops & Wakeups Restart} - hint="Recurring follow-up loops and one-shot scheduleWakeup tool. Disabled by default — toggling requires a server restart to (de)wire infrastructure." - > - - - - MCP OAuth Restart} - hint="Handle OAuth flows for MCP servers that require authentication. Disabled by default — toggling requires a server restart." - > - - - - {mcpOauthEnabled && ( - MCP OAuth auto-refresh Restart} - hint="Periodically dedup ~/.copilot/mcp-oauth-config/ and refresh AAD-backed tokens before they expire so HTTP MCP servers don't re-prompt for auth. Disabled by default — toggling requires a server restart." - > - - - - )} - - - - -
+ {FEATURE_CARD_GROUPS.map(group => ( +
+
{group.heading}
+ {getFeatureCardSettings(group.id).map(def => { + const ui = def.ui!; + if (ui.dependsOn && featureValues[ui.dependsOn] !== true) { + return null; + } + const badge = ui.badge ? FEATURE_BADGES[ui.badge] : undefined; + const name = badge + ? <>{ui.label} {badge.label} + : ui.label; + return ( + + + {ui.control?.type === 'select' ? ( + + ) : ( + setFeatureValues(prev => ({ ...prev, [def.key]: checked }))} + data-testid={ui.testId} + /> + )} + + ); + })} +
+ ))} )} diff --git a/packages/coc/src/server/spa/client/react/utils/config.ts b/packages/coc/src/server/spa/client/react/utils/config.ts index fc5d7080a..4d7c9c627 100644 --- a/packages/coc/src/server/spa/client/react/utils/config.ts +++ b/packages/coc/src/server/spa/client/react/utils/config.ts @@ -11,6 +11,13 @@ interface DashboardConfig { apiBasePath: string; wsPath: string; hostname?: string; + /** + * Raw feature-flag map as embedded by the server / returned by + * GET /api/config/runtime. Flags are flattened onto this object on read, + * so new registry-driven flags need no per-flag plumbing here — read them + * with isFeatureEnabled()/getFeatureValue() or add a typed accessor below. + */ + features?: Record; terminalEnabled?: boolean; notesEnabled?: boolean; myWorkEnabled?: boolean; @@ -67,6 +74,11 @@ function getBootstrapConfig(): DashboardConfig { if (!config) { return { apiBasePath: '/api', wsPath: '/ws' }; } + // Flatten the embedded feature map so flags are readable as top-level + // config fields (legacy flat embeds keep working unchanged). + if (config.features && typeof config.features === 'object') { + return { ...config, ...config.features }; + } return config; } @@ -75,6 +87,20 @@ function getConfig(): DashboardConfig { return getBootstrapConfig(); } +/** + * Generic read of a boolean runtime feature flag by its + * RuntimeDashboardConfig.features name (e.g. 'serversEnabled'). + * Prefer this (or a typed accessor below) for new registry-driven flags. + */ +export function isFeatureEnabled(flag: string): boolean { + return (getConfig() as unknown as Record)[flag] === true; +} + +/** Generic read of a non-boolean runtime feature value (e.g. 'scratchpadLayout'). */ +export function getFeatureValue(flag: string): unknown { + return (getConfig() as unknown as Record)[flag]; +} + /** * Fetch fresh feature flags from GET /api/config/runtime and merge them * into the active config. Called once on page load from App initialization. @@ -102,42 +128,14 @@ async function _fetchAndApplyRuntimeConfig(apiBase: string): Promise { const data = await resp.json(); if (!data || typeof data !== 'object' || !data.features) return; - // Merge runtime features into the active config, preserving bootstrap-only fields + // Merge runtime features into the active config, preserving + // bootstrap-only fields. Flags are spread flat so every flag in + // RuntimeDashboardConfig.features lands on the config without + // per-flag plumbing. _runtimeConfig = { ...bootstrap, - terminalEnabled: data.features.terminalEnabled, - notesEnabled: data.features.notesEnabled, - myWorkEnabled: data.features.myWorkEnabled, - myLifeEnabled: data.features.myLifeEnabled, - scratchpadEnabled: data.features.scratchpadEnabled, - scratchpadLayout: data.features.scratchpadLayout, - workflowsEnabled: data.features.workflowsEnabled, - pullRequestsEnabled: data.features.pullRequestsEnabled, - pullRequestsSuggestionsEnabled: data.features.pullRequestsSuggestionsEnabled, - pullRequestsAutoClassifyTeamEnabled: data.features.pullRequestsAutoClassifyTeamEnabled, - serversEnabled: data.features.serversEnabled, - ralphEnabled: data.features.ralphEnabled, - forEachEnabled: data.features.forEachEnabled, - mapReduceEnabled: data.features.mapReduceEnabled, - vimNavigationEnabled: data.features.vimNavigationEnabled, - loopsEnabled: data.features.loopsEnabled, - excalidrawEnabled: data.features.excalidrawEnabled, - mcpOauthEnabled: data.features.mcpOauthEnabled, - focusedDiffEnabled: data.features.focusedDiffEnabled, - sessionContextAttachmentsEnabled: data.features.sessionContextAttachmentsEnabled, - commitChatLensEnabled: data.features.commitChatLensEnabled, - commitChatLensDormantMode: data.features.commitChatLensDormantMode, - containerDefaultAgentEnabled: data.features.containerDefaultAgentEnabled, - codexEnabled: data.features.codexEnabled, - defaultProvider: data.features.defaultProvider, - autoAgentProviderRoutingEnabled: data.features.autoAgentProviderRoutingEnabled, - workItemsHierarchyEnabled: data.features.workItemsHierarchyEnabled, - workItemsSyncEnabled: data.features.workItemsSyncEnabled, - workItemsAiAuthoringEnabled: data.features.workItemsAiAuthoringEnabled, - workItemsWorkflowEnabled: data.features.workItemsWorkflowEnabled, - gitCommitLookupEnabled: data.features.gitCommitLookupEnabled, - gitCrossCloneCherryPickEnabled: data.features.gitCrossCloneCherryPickEnabled, - effortLevelsEnabled: data.features.effortLevelsEnabled, + ...data.features, + features: data.features, hostname: data.hostname ?? bootstrap.hostname, bindAddress: data.bindAddress ?? bootstrap.bindAddress, }; diff --git a/packages/coc/src/server/spa/html-template.ts b/packages/coc/src/server/spa/html-template.ts index 4a95d70ab..c714ee989 100644 --- a/packages/coc/src/server/spa/html-template.ts +++ b/packages/coc/src/server/spa/html-template.ts @@ -76,6 +76,11 @@ export function getBundleETag(configRevision?: number): string { return etag; } +/** JSON.stringify with `<` escaped so the payload is safe inside a ${reviewFilePath ? `