From ce6c863349979cac2a048961b05dcb9eeaad7a58 Mon Sep 17 00:00:00 2001 From: ejclaw Date: Mon, 4 May 2026 03:03:05 +0900 Subject: [PATCH] Add room skill settings inventory --- .../src/RuntimeInventorySettings.css | 63 ++++- .../src/RuntimeInventorySettings.tsx | 104 +++++++- apps/dashboard/src/api.ts | 39 +++ scripts/dashboard-ux.ts | 75 ++++++ src/db.ts | 1 + src/db/base-schema.ts | 24 +- src/db/bootstrap.test.ts | 1 + src/db/migrations/016_room-skill-overrides.ts | 29 +++ src/db/migrations/index.ts | 2 + src/db/rooms.ts | 66 +++++ src/db/runtime-tasks-rooms.ts | 10 + src/room-skill-settings.test.ts | 195 ++++++++++++++ src/room-skill-settings.ts | 239 ++++++++++++++++++ src/web-dashboard-settings-routes.test.ts | 41 +++ src/web-dashboard-settings-routes.ts | 19 ++ 15 files changed, 902 insertions(+), 6 deletions(-) create mode 100644 src/db/migrations/016_room-skill-overrides.ts create mode 100644 src/room-skill-settings.test.ts create mode 100644 src/room-skill-settings.ts diff --git a/apps/dashboard/src/RuntimeInventorySettings.css b/apps/dashboard/src/RuntimeInventorySettings.css index a35d15e..c67465e 100644 --- a/apps/dashboard/src/RuntimeInventorySettings.css +++ b/apps/dashboard/src/RuntimeInventorySettings.css @@ -114,9 +114,67 @@ line-height: 1.4; } +.runtime-room-skill-list { + display: grid; + gap: 12px; + margin-top: 14px; +} + +.runtime-room-skill-card { + display: grid; + gap: 12px; + border-radius: 16px; + background: rgba(15, 23, 42, 0.38); + padding: 14px; +} + +.runtime-room-skill-card > header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.runtime-room-skill-card > header > div { + display: grid; + gap: 3px; + min-width: 0; +} + +.runtime-room-skill-card span, +.runtime-room-agent-policy small { + color: var(--muted); +} + +.runtime-room-skill-card code { + color: var(--muted); + font-size: 0.72rem; + overflow-wrap: anywhere; +} + +.runtime-room-agent-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.runtime-room-agent-policy { + display: grid; + gap: 4px; + border: 1px solid rgba(148, 163, 184, 0.14); + border-radius: 14px; + padding: 12px; +} + +.runtime-room-agent-policy > span { + color: var(--text); + font-size: 0.86rem; +} + @media (max-width: 980px) { .runtime-summary-card, - .runtime-skill-grid { + .runtime-skill-grid, + .runtime-room-agent-grid { grid-template-columns: 1fr; } } @@ -124,7 +182,8 @@ @media (max-width: 720px) { .runtime-card-head, .runtime-skill-card header, - .runtime-path-row { + .runtime-path-row, + .runtime-room-skill-card > header { flex-direction: column; } } diff --git a/apps/dashboard/src/RuntimeInventorySettings.tsx b/apps/dashboard/src/RuntimeInventorySettings.tsx index 3b4f51b..7753f94 100644 --- a/apps/dashboard/src/RuntimeInventorySettings.tsx +++ b/apps/dashboard/src/RuntimeInventorySettings.tsx @@ -1,11 +1,14 @@ import { useEffect, useState } from 'react'; import { + fetchRoomSkillSettings, fetchRuntimeInventory, type RuntimeAgentInventory, type RuntimeInventorySnapshot, type RuntimePathSnapshot, type RuntimeSkillDirSnapshot, + type RoomSkillCatalogItem, + type RoomSkillSettingsSnapshot, } from './api'; import { SettingsSectionHeading } from './SettingsPanelChrome'; import './RuntimeInventorySettings.css'; @@ -98,18 +101,114 @@ function AgentInventoryCard({ ); } +function agentLabel(agentType: 'claude-code' | 'codex') { + return agentType === 'codex' ? 'Codex' : 'Claude Code'; +} + +function RoomSkillPolicyCard({ + snapshot, +}: { + snapshot: RoomSkillSettingsSnapshot | null; +}) { + if (!snapshot) { + return ( +
+
+

방별 스킬 정책

+
+

방별 스킬 정책을 불러오는 중…

+
+ ); + } + + const catalogById = new Map( + snapshot.catalog.map((skill) => [skill.id, skill]), + ); + const roomPreview = snapshot.rooms.slice(0, 8); + + return ( +
+
+
+

방별 스킬 정책

+

+ 전역 설정은 읽기 전용입니다. 이 목록은 다음 토글 PR에서 방 단위 + enable/disable 대상으로 쓰입니다. +

+
+ + {snapshot.catalog.length} skills · {snapshot.rooms.length} rooms + +
+ + {roomPreview.length === 0 ? ( +

등록된 방이 없습니다.

+ ) : ( +
+ {roomPreview.map((room) => ( +
+
+
+ {room.name} + {room.folder} +
+ {room.jid} +
+
+ {room.agents.map((agent) => { + const disabledNames = agent.disabledSkillIds + .map((id) => catalogById.get(id)?.displayName ?? id) + .slice(0, 3); + return ( +
+ {agentLabel(agent.agentType)} + + {agent.mode === 'all-enabled' + ? '기본 전체 ON' + : `${agent.disabledSkillIds.length}개 OFF`} + + + 사용 {agent.effectiveEnabledSkillIds.length} / 가능{' '} + {agent.availableSkillIds.length} + + {disabledNames.length > 0 ? ( + OFF: {disabledNames.join(', ')} + ) : null} +
+ ); + })} +
+
+ ))} +
+ )} + {snapshot.rooms.length > roomPreview.length ? ( + + 외 {snapshot.rooms.length - roomPreview.length}개 방 더 있음 + + ) : null} +
+ ); +} + export function RuntimeInventorySettings() { const [snapshot, setSnapshot] = useState( null, ); + const [roomSkillSnapshot, setRoomSkillSnapshot] = + useState(null); const [error, setError] = useState(null); useEffect(() => { let cancelled = false; - fetchRuntimeInventory() - .then((value) => { + Promise.all([fetchRuntimeInventory(), fetchRoomSkillSettings()]) + .then(([value, roomSkills]) => { if (cancelled) return; setSnapshot(value); + setRoomSkillSnapshot(roomSkills); setError(null); }) .catch((err) => { @@ -156,6 +255,7 @@ export function RuntimeInventorySettings() { +
diff --git a/apps/dashboard/src/api.ts b/apps/dashboard/src/api.ts index d7e5776..ce1dbe6 100644 --- a/apps/dashboard/src/api.ts +++ b/apps/dashboard/src/api.ts @@ -438,6 +438,41 @@ export interface RuntimeInventorySnapshot { }; } +export type RoomSkillScope = 'codex-user' | 'claude-user' | 'runner'; + +export interface RoomSkillCatalogItem { + id: string; + scope: RoomSkillScope; + name: string; + displayName: string; + description: string | null; + path: string; + agentTypes: Array<'claude-code' | 'codex'>; +} + +export interface RoomSkillAgentPolicy { + agentType: 'claude-code' | 'codex'; + mode: 'all-enabled' | 'custom'; + availableSkillIds: string[]; + disabledSkillIds: string[]; + explicitEnabledSkillIds: string[]; + effectiveEnabledSkillIds: string[]; +} + +export interface RoomSkillPolicyRoom { + jid: string; + name: string; + folder: string; + roomMode?: 'single' | 'tribunal'; + agents: RoomSkillAgentPolicy[]; +} + +export interface RoomSkillSettingsSnapshot { + generatedAt: string; + catalog: RoomSkillCatalogItem[]; + rooms: RoomSkillPolicyRoom[]; +} + export interface MoaReferenceStatus { model: string; checkedAt: string; @@ -596,6 +631,10 @@ export async function fetchRuntimeInventory(): Promise return fetchJson('/api/settings/runtime-inventory'); } +export async function fetchRoomSkillSettings(): Promise { + return fetchJson('/api/settings/room-skills'); +} + export async function updateCodexFeatures( input: Partial, ): Promise { diff --git a/scripts/dashboard-ux.ts b/scripts/dashboard-ux.ts index e14ad6e..28071bc 100644 --- a/scripts/dashboard-ux.ts +++ b/scripts/dashboard-ux.ts @@ -319,6 +319,11 @@ async function handleMockApi(route: Route, state: MockApiState) { return; } + if (method === 'GET' && url.pathname === '/api/settings/room-skills') { + await fulfillJson(route, roomSkillSettingsFixture()); + return; + } + if (url.pathname === '/api/settings/codex-features') { if (method === 'GET') { await fulfillJson(route, state.codexFeatures); @@ -583,6 +588,76 @@ function runtimeInventoryFixture() { }; } +function roomSkillSettingsFixture() { + return { + generatedAt: '2026-05-04T00:00:00.000Z', + catalog: [ + { + id: 'codex-user:agent-browser', + scope: 'codex-user', + name: 'agent-browser', + displayName: 'agent-browser', + description: 'Browser automation', + path: '/home/.agents/skills/agent-browser', + agentTypes: ['codex'], + }, + { + id: 'claude-user:review-helper', + scope: 'claude-user', + name: 'review-helper', + displayName: 'review-helper', + description: 'Review workflow', + path: '/home/.claude/skills/review-helper', + agentTypes: ['claude-code'], + }, + { + id: 'runner:agent-browser', + scope: 'runner', + name: 'agent-browser', + displayName: 'agent-browser', + description: 'Browser automation', + path: '/repo/runners/skills/agent-browser', + agentTypes: ['claude-code', 'codex'], + }, + ], + rooms: [ + { + jid: 'room@example', + name: 'EJClaw', + folder: 'ejclaw', + roomMode: 'tribunal', + agents: [ + { + agentType: 'codex', + mode: 'all-enabled', + availableSkillIds: [ + 'codex-user:agent-browser', + 'runner:agent-browser', + ], + disabledSkillIds: [], + explicitEnabledSkillIds: [], + effectiveEnabledSkillIds: [ + 'codex-user:agent-browser', + 'runner:agent-browser', + ], + }, + { + agentType: 'claude-code', + mode: 'custom', + availableSkillIds: [ + 'claude-user:review-helper', + 'runner:agent-browser', + ], + disabledSkillIds: ['claude-user:review-helper'], + explicitEnabledSkillIds: [], + effectiveEnabledSkillIds: ['runner:agent-browser'], + }, + ], + }, + ], + }; +} + function parseJsonBody(body: string | null): Record { if (!body) return {}; const parsed = JSON.parse(body) as unknown; diff --git a/src/db.ts b/src/db.ts index 83ca622..34b2704 100644 --- a/src/db.ts +++ b/src/db.ts @@ -62,6 +62,7 @@ export { getSessionForAgentType, getStoredRoomRoleAgentPlan, getStoredRoomSettings, + getStoredRoomSkillOverrides, getTaskById, getTasksForGroup, hasActiveCiWatcherForChat, diff --git a/src/db/base-schema.ts b/src/db/base-schema.ts index 7806d61..02bd003 100644 --- a/src/db/base-schema.ts +++ b/src/db/base-schema.ts @@ -1,5 +1,25 @@ import { Database } from 'bun:sqlite'; +const ROOM_SKILL_OVERRIDES_SCHEMA = ` + CREATE TABLE IF NOT EXISTS room_skill_overrides ( + chat_jid TEXT NOT NULL, + agent_type TEXT NOT NULL, + skill_scope TEXT NOT NULL, + skill_name TEXT NOT NULL, + enabled INTEGER NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (chat_jid, agent_type, skill_scope, skill_name), + FOREIGN KEY (chat_jid) REFERENCES room_settings(chat_jid) ON DELETE CASCADE, + CHECK (agent_type IN ('claude-code', 'codex')), + CHECK (enabled IN (0, 1)), + CHECK (length(skill_scope) > 0), + CHECK (length(skill_name) > 0) + ); + CREATE INDEX IF NOT EXISTS idx_room_skill_overrides_room + ON room_skill_overrides(chat_jid, agent_type); +`; + export function applyBaseSchema(database: Database): void { database.exec(` CREATE TABLE IF NOT EXISTS chats ( @@ -410,8 +430,7 @@ export function applyBaseSchema(database: Database): void { last_used_at TEXT, archived_at TEXT ); - CREATE INDEX IF NOT EXISTS idx_memories_scope - ON memories(scope_kind, scope_key); + CREATE INDEX IF NOT EXISTS idx_memories_scope ON memories(scope_kind, scope_key); CREATE INDEX IF NOT EXISTS idx_memories_active ON memories(scope_kind, scope_key, archived_at, created_at); CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5( @@ -435,4 +454,5 @@ export function applyBaseSchema(database: Database): void { VALUES (new.id, new.content, new.keywords_json); END; `); + database.exec(ROOM_SKILL_OVERRIDES_SCHEMA); } diff --git a/src/db/bootstrap.test.ts b/src/db/bootstrap.test.ts index f0c20ce..6015b98 100644 --- a/src/db/bootstrap.test.ts +++ b/src/db/bootstrap.test.ts @@ -40,6 +40,7 @@ function getExpectedSchemaMigrations(): Array<{ { version: 13, name: 'message_source_kind' }, { version: 14, name: 'work_item_attachments' }, { version: 15, name: 'turn_progress_text' }, + { version: 16, name: 'room_skill_overrides' }, ]; } diff --git a/src/db/migrations/016_room-skill-overrides.ts b/src/db/migrations/016_room-skill-overrides.ts new file mode 100644 index 0000000..e94c81a --- /dev/null +++ b/src/db/migrations/016_room-skill-overrides.ts @@ -0,0 +1,29 @@ +import type { Database } from 'bun:sqlite'; + +import type { SchemaMigrationDefinition } from './types.js'; + +export const ROOM_SKILL_OVERRIDES_MIGRATION: SchemaMigrationDefinition = { + version: 16, + name: 'room_skill_overrides', + apply(database: Database) { + database.exec(` + CREATE TABLE IF NOT EXISTS room_skill_overrides ( + chat_jid TEXT NOT NULL, + agent_type TEXT NOT NULL, + skill_scope TEXT NOT NULL, + skill_name TEXT NOT NULL, + enabled INTEGER NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (chat_jid, agent_type, skill_scope, skill_name), + FOREIGN KEY (chat_jid) REFERENCES room_settings(chat_jid) ON DELETE CASCADE, + CHECK (agent_type IN ('claude-code', 'codex')), + CHECK (enabled IN (0, 1)), + CHECK (length(skill_scope) > 0), + CHECK (length(skill_name) > 0) + ); + CREATE INDEX IF NOT EXISTS idx_room_skill_overrides_room + ON room_skill_overrides(chat_jid, agent_type); + `); + }, +}; diff --git a/src/db/migrations/index.ts b/src/db/migrations/index.ts index f3e8fee..6ccb029 100644 --- a/src/db/migrations/index.ts +++ b/src/db/migrations/index.ts @@ -15,6 +15,7 @@ import { PAIRED_VERDICT_AND_STEP_TELEMETRY_MIGRATION } from './012_paired-verdic import { MESSAGE_SOURCE_KIND_MIGRATION } from './013_message-source-kind.js'; import { WORK_ITEM_ATTACHMENTS_MIGRATION } from './014_work-item-attachments.js'; import { TURN_PROGRESS_TEXT_MIGRATION } from './015_turn-progress-text.js'; +import { ROOM_SKILL_OVERRIDES_MIGRATION } from './016_room-skill-overrides.js'; import type { SchemaMigrationArgs, SchemaMigrationDefinition, @@ -38,6 +39,7 @@ const ORDERED_SCHEMA_MIGRATIONS: readonly SchemaMigrationDefinition[] = [ MESSAGE_SOURCE_KIND_MIGRATION, WORK_ITEM_ATTACHMENTS_MIGRATION, TURN_PROGRESS_TEXT_MIGRATION, + ROOM_SKILL_OVERRIDES_MIGRATION, ]; function ensureSchemaMigrationsTable(database: Database): void { diff --git a/src/db/rooms.ts b/src/db/rooms.ts index f50f933..f9d9640 100644 --- a/src/db/rooms.ts +++ b/src/db/rooms.ts @@ -28,6 +28,16 @@ interface StoredRoomModeRow { source: RoomModeSource; } +export interface StoredRoomSkillOverride { + chatJid: string; + agentType: AgentType; + skillScope: string; + skillName: string; + enabled: boolean; + createdAt: string; + updatedAt: string; +} + export interface AssignRoomInput { name: string; roomMode?: RoomMode; @@ -307,6 +317,62 @@ export function getStoredRoomRoleAgentPlanFromDatabase( return stored ? resolveStoredRoomRoleAgentPlan(database, stored) : undefined; } +export function getStoredRoomSkillOverridesFromDatabase( + database: Database, + chatJid?: string, +): StoredRoomSkillOverride[] { + const params: string[] = []; + const where = chatJid ? 'WHERE chat_jid = ?' : ''; + if (chatJid) params.push(chatJid); + + let rows: Array<{ + chat_jid: string; + agent_type: string; + skill_scope: string; + skill_name: string; + enabled: number; + created_at: string; + updated_at: string; + }>; + try { + rows = database + .prepare( + `SELECT chat_jid, agent_type, skill_scope, skill_name, enabled, + created_at, updated_at + FROM room_skill_overrides + ${where} + ORDER BY chat_jid, agent_type, skill_scope, skill_name`, + ) + .all(...params) as Array<{ + chat_jid: string; + agent_type: string; + skill_scope: string; + skill_name: string; + enabled: number; + created_at: string; + updated_at: string; + }>; + } catch { + return []; + } + + return rows + .map((row) => { + const agentType = normalizeStoredAgentType(row.agent_type); + if (!agentType) return null; + return { + chatJid: row.chat_jid, + agentType, + skillScope: row.skill_scope, + skillName: row.skill_name, + enabled: row.enabled === 1, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; + }) + .filter((row): row is StoredRoomSkillOverride => Boolean(row)); +} + function getStoredRoomModeRowFromDatabase( database: Database, chatJid: string, diff --git a/src/db/runtime-tasks-rooms.ts b/src/db/runtime-tasks-rooms.ts index 6f94077..a1a8b7e 100644 --- a/src/db/runtime-tasks-rooms.ts +++ b/src/db/runtime-tasks-rooms.ts @@ -36,9 +36,11 @@ import { getRegisteredGroupFromDatabase, getStoredRoomRoleAgentPlanFromDatabase, getStoredRoomSettingsFromDatabase, + getStoredRoomSkillOverridesFromDatabase, setExplicitRoomModeInDatabase, setRegisteredGroupForTestsInDatabase, setStoredRoomOwnerAgentTypeForTestsInDatabase, + type StoredRoomSkillOverride, updateRegisteredGroupNameInDatabase, } from './rooms.js'; import { type StoredRoomSettings } from './room-registration.js'; @@ -363,6 +365,14 @@ export function getStoredRoomRoleAgentPlan( return getStoredRoomRoleAgentPlanFromDatabase(db, chatJid); } +export function getStoredRoomSkillOverrides( + chatJid?: string, +): StoredRoomSkillOverride[] { + const db = getDatabaseIfInitialized(); + if (!db) return []; + return getStoredRoomSkillOverridesFromDatabase(db, chatJid); +} + export function getExplicitRoomMode(chatJid: string): RoomMode | undefined { return getExplicitRoomModeFromDatabase(requireDatabase(), chatJid); } diff --git a/src/room-skill-settings.test.ts b/src/room-skill-settings.test.ts new file mode 100644 index 0000000..813b873 --- /dev/null +++ b/src/room-skill-settings.test.ts @@ -0,0 +1,195 @@ +import { Database } from 'bun:sqlite'; + +import { describe, expect, it } from 'vitest'; + +import { initializeDatabaseSchema } from './db/bootstrap.js'; +import { getStoredRoomSkillOverridesFromDatabase } from './db/rooms.js'; +import { + buildRoomSkillSettingsSnapshot, + type RoomSkillSettingsBuildInput, +} from './room-skill-settings.js'; +import type { RuntimeInventorySnapshot } from './runtime-inventory.js'; + +function skill(name: string, skillPath: string, description = `${name} desc`) { + return { name, description, path: skillPath }; +} + +function inventoryFixture(): RuntimeInventorySnapshot { + return { + generatedAt: '2026-05-04T00:00:00.000Z', + projectRoot: '/repo', + dataDir: '/repo/data', + service: { + id: 'codex-main', + sessionScope: 'codex-main', + agentType: 'codex', + }, + codex: { + configFiles: [], + skillDirs: [ + { + label: 'Codex user skills', + path: '/home/.agents/skills', + exists: true, + count: 1, + skills: [skill('agent-browser', '/home/.agents/skills/browser')], + }, + ], + mcp: { + configPath: { + label: 'Codex config.toml', + path: '/home/.codex/config.toml', + exists: true, + }, + ejclawConfigured: true, + serverCount: 1, + }, + }, + claude: { + configFiles: [], + skillDirs: [ + { + label: 'Claude user skills', + path: '/home/.claude/skills', + exists: true, + count: 1, + skills: [ + skill('review-helper', '/home/.claude/skills/review-helper'), + ], + }, + ], + mcp: { + configPath: { + label: 'Claude settings.json', + path: '/home/.claude/settings.json', + exists: true, + }, + ejclawConfigured: true, + serverCount: 1, + }, + }, + ejclaw: { + runnerSkillDir: { + label: 'EJClaw runner skills', + path: '/repo/runners/skills', + exists: true, + count: 1, + skills: [ + skill('runner-browser', '/repo/runners/skills/runner-browser'), + ], + }, + mcpServer: { + label: 'EJClaw IPC MCP server', + path: '/repo/runners/agent-runner/dist/ipc-mcp-stdio.js', + exists: true, + }, + }, + }; +} + +describe('room skill settings', () => { + it('builds room-scoped effective skill policy from inventory and overrides', () => { + const input: RoomSkillSettingsBuildInput = { + generatedAt: '2026-05-04T00:00:00.000Z', + inventory: inventoryFixture(), + roomBindings: { + 'room-1': { + name: 'Room One', + folder: 'room-one', + added_at: '2026-05-04T00:00:00.000Z', + agentType: 'codex', + }, + }, + registeredAgentTypesByJid: new Map([ + ['room-1', ['claude-code', 'codex']], + ]), + overrides: [ + { + chatJid: 'room-1', + agentType: 'claude-code', + skillScope: 'claude-user', + skillName: 'review-helper', + enabled: false, + createdAt: '2026-05-04T00:00:00.000Z', + updatedAt: '2026-05-04T00:00:00.000Z', + }, + ], + }; + + const snapshot = buildRoomSkillSettingsSnapshot(input); + + expect(snapshot.catalog.map((skill) => skill.id).sort()).toEqual([ + 'claude-user:review-helper', + 'codex-user:browser', + 'runner:runner-browser', + ]); + expect(snapshot.rooms).toHaveLength(1); + const room = snapshot.rooms[0]!; + expect(room.agents.map((agent) => agent.agentType)).toEqual([ + 'claude-code', + 'codex', + ]); + expect( + room.agents.find((agent) => agent.agentType === 'codex'), + ).toMatchObject({ + mode: 'all-enabled', + availableSkillIds: ['codex-user:browser', 'runner:runner-browser'], + effectiveEnabledSkillIds: ['codex-user:browser', 'runner:runner-browser'], + }); + expect( + room.agents.find((agent) => agent.agentType === 'claude-code'), + ).toMatchObject({ + mode: 'custom', + availableSkillIds: ['claude-user:review-helper', 'runner:runner-browser'], + disabledSkillIds: ['claude-user:review-helper'], + effectiveEnabledSkillIds: ['runner:runner-browser'], + }); + }); + + it('reads normalized room skill overrides from the database', () => { + const database = new Database(':memory:'); + try { + initializeDatabaseSchema(database); + database + .prepare( + `INSERT INTO room_settings ( + chat_jid, room_mode, mode_source, name, folder, updated_at + ) VALUES (?, 'single', 'explicit', ?, ?, ?)`, + ) + .run('room-1', 'Room One', 'room-one', '2026-05-04T00:00:00.000Z'); + database + .prepare( + `INSERT INTO room_skill_overrides ( + chat_jid, agent_type, skill_scope, skill_name, enabled, + created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + 'room-1', + 'codex', + 'runner', + 'agent-browser', + 0, + '2026-05-04T00:00:00.000Z', + '2026-05-04T00:00:00.000Z', + ); + + expect(getStoredRoomSkillOverridesFromDatabase(database)).toEqual([ + { + chatJid: 'room-1', + agentType: 'codex', + skillScope: 'runner', + skillName: 'agent-browser', + enabled: false, + createdAt: '2026-05-04T00:00:00.000Z', + updatedAt: '2026-05-04T00:00:00.000Z', + }, + ]); + expect( + getStoredRoomSkillOverridesFromDatabase(database, 'missing-room'), + ).toEqual([]); + } finally { + database.close(); + } + }); +}); diff --git a/src/room-skill-settings.ts b/src/room-skill-settings.ts new file mode 100644 index 0000000..448a064 --- /dev/null +++ b/src/room-skill-settings.ts @@ -0,0 +1,239 @@ +import path from 'node:path'; + +import { + getAllRoomBindings, + getRegisteredAgentTypesForJid, + getStoredRoomSkillOverrides, +} from './db.js'; +import { + getRuntimeInventory, + type RuntimeInventorySnapshot, + type RuntimeSkillDirSnapshot, + type RuntimeSkillSummary, +} from './runtime-inventory.js'; +import type { AgentType, RegisteredGroup, RoomMode } from './types.js'; +import type { StoredRoomSkillOverride } from './db/rooms.js'; + +export type RoomSkillScope = 'codex-user' | 'claude-user' | 'runner'; + +export interface RoomSkillCatalogItem { + id: string; + scope: RoomSkillScope; + name: string; + displayName: string; + description: string | null; + path: string; + agentTypes: AgentType[]; +} + +export interface RoomSkillAgentPolicy { + agentType: AgentType; + mode: 'all-enabled' | 'custom'; + availableSkillIds: string[]; + disabledSkillIds: string[]; + explicitEnabledSkillIds: string[]; + effectiveEnabledSkillIds: string[]; +} + +export interface RoomSkillPolicyRoom { + jid: string; + name: string; + folder: string; + roomMode?: RoomMode; + agents: RoomSkillAgentPolicy[]; +} + +export interface RoomSkillSettingsSnapshot { + generatedAt: string; + catalog: RoomSkillCatalogItem[]; + rooms: RoomSkillPolicyRoom[]; +} + +export interface RoomSkillSettingsBuildInput { + generatedAt?: string; + inventory: RuntimeInventorySnapshot; + roomBindings: Record; + registeredAgentTypesByJid?: Map; + overrides?: StoredRoomSkillOverride[]; +} + +function skillId(scope: RoomSkillScope, name: string): string { + return `${scope}:${name}`; +} + +function skillNameFromPath(skill: RuntimeSkillSummary): string { + return path.basename(skill.path); +} + +function addSkillsFromDir( + catalog: Map, + dir: RuntimeSkillDirSnapshot, + scope: RoomSkillScope, + agentTypes: AgentType[], +): void { + if (!dir.exists) return; + for (const skill of dir.skills) { + const name = skillNameFromPath(skill); + if (!name) continue; + const id = skillId(scope, name); + const existing = catalog.get(id); + if (existing) { + const types = new Set([...existing.agentTypes, ...agentTypes]); + existing.agentTypes = [...types].sort(); + continue; + } + catalog.set(id, { + id, + scope, + name, + displayName: skill.name, + description: skill.description, + path: skill.path, + agentTypes: [...agentTypes].sort(), + }); + } +} + +export function buildRoomSkillCatalog( + inventory: RuntimeInventorySnapshot, +): RoomSkillCatalogItem[] { + const catalog = new Map(); + const runnerPath = inventory.ejclaw.runnerSkillDir.path; + const codexUserDir = inventory.codex.skillDirs.find( + (dir) => dir.path !== runnerPath, + ); + const claudeUserDir = inventory.claude.skillDirs.find( + (dir) => dir.path !== runnerPath, + ); + + if (codexUserDir) { + addSkillsFromDir(catalog, codexUserDir, 'codex-user', ['codex']); + } + if (claudeUserDir) { + addSkillsFromDir(catalog, claudeUserDir, 'claude-user', ['claude-code']); + } + addSkillsFromDir(catalog, inventory.ejclaw.runnerSkillDir, 'runner', [ + 'claude-code', + 'codex', + ]); + + return [...catalog.values()].sort((a, b) => { + const scopeCompare = a.scope.localeCompare(b.scope); + return scopeCompare === 0 + ? a.displayName.localeCompare(b.displayName) + : scopeCompare; + }); +} + +function overrideKey( + jid: string, + agentType: AgentType, + scope: string, + name: string, +): string { + return `${jid}\u0000${agentType}\u0000${scope}\u0000${name}`; +} + +function resolveRoomAgentTypes( + jid: string, + group: RegisteredGroup, + registeredAgentTypesByJid?: Map, +): AgentType[] { + const registered = registeredAgentTypesByJid?.get(jid) ?? []; + const types = new Set(registered); + if (group.agentType) types.add(group.agentType); + if (types.size === 0) types.add('claude-code'); + return [...types].sort(); +} + +export function buildRoomSkillSettingsSnapshot({ + generatedAt, + inventory, + roomBindings, + registeredAgentTypesByJid, + overrides = [], +}: RoomSkillSettingsBuildInput): RoomSkillSettingsSnapshot { + const catalog = buildRoomSkillCatalog(inventory); + const overridesByKey = new Map( + overrides.map((override) => [ + overrideKey( + override.chatJid, + override.agentType, + override.skillScope, + override.skillName, + ), + override, + ]), + ); + + const rooms = Object.entries(roomBindings) + .map(([jid, group]) => { + const agentTypes = resolveRoomAgentTypes( + jid, + group, + registeredAgentTypesByJid, + ); + return { + jid, + name: group.name, + folder: group.folder, + agents: agentTypes.map((agentType) => { + const available = catalog.filter((skill) => + skill.agentTypes.includes(agentType), + ); + const disabledSkillIds: string[] = []; + const explicitEnabledSkillIds: string[] = []; + const effectiveEnabledSkillIds: string[] = []; + + for (const skill of available) { + const override = overridesByKey.get( + overrideKey(jid, agentType, skill.scope, skill.name), + ); + if (override?.enabled === false) { + disabledSkillIds.push(skill.id); + continue; + } + if (override?.enabled === true) { + explicitEnabledSkillIds.push(skill.id); + } + effectiveEnabledSkillIds.push(skill.id); + } + + return { + agentType, + mode: + disabledSkillIds.length > 0 || explicitEnabledSkillIds.length > 0 + ? 'custom' + : 'all-enabled', + availableSkillIds: available.map((skill) => skill.id), + disabledSkillIds, + explicitEnabledSkillIds, + effectiveEnabledSkillIds, + } satisfies RoomSkillAgentPolicy; + }), + } satisfies RoomSkillPolicyRoom; + }) + .sort((a, b) => a.name.localeCompare(b.name)); + + return { + generatedAt: generatedAt ?? new Date().toISOString(), + catalog, + rooms, + }; +} + +export function getRoomSkillSettings(): RoomSkillSettingsSnapshot { + const roomBindings = getAllRoomBindings(); + const registeredAgentTypesByJid = new Map( + Object.keys(roomBindings).map((jid) => [ + jid, + getRegisteredAgentTypesForJid(jid), + ]), + ); + return buildRoomSkillSettingsSnapshot({ + inventory: getRuntimeInventory(), + roomBindings, + registeredAgentTypesByJid, + overrides: getStoredRoomSkillOverrides(), + }); +} diff --git a/src/web-dashboard-settings-routes.test.ts b/src/web-dashboard-settings-routes.test.ts index ebdaeaa..f3ca628 100644 --- a/src/web-dashboard-settings-routes.test.ts +++ b/src/web-dashboard-settings-routes.test.ts @@ -12,6 +12,7 @@ import type { ModelConfigSnapshot, } from './settings-store.js'; import type { RuntimeInventorySnapshot } from './runtime-inventory.js'; +import type { RoomSkillSettingsSnapshot } from './room-skill-settings.js'; import type { MoaSettingsSnapshot } from './settings-store-moa.js'; function jsonResponse(value: unknown, init?: ResponseInit): Response { @@ -158,6 +159,38 @@ const moaSettings: MoaSettingsSnapshot = { ], }; +const roomSkillSettings: RoomSkillSettingsSnapshot = { + generatedAt: '2026-05-04T00:00:00.000Z', + catalog: [ + { + id: 'runner:agent-browser', + scope: 'runner', + name: 'agent-browser', + displayName: 'agent-browser', + description: 'Browser automation', + path: '/repo/runners/skills/agent-browser', + agentTypes: ['claude-code', 'codex'], + }, + ], + rooms: [ + { + jid: 'room@example', + name: 'EJClaw', + folder: 'ejclaw', + agents: [ + { + agentType: 'codex', + mode: 'all-enabled', + availableSkillIds: ['runner:agent-browser'], + disabledSkillIds: [], + explicitEnabledSkillIds: [], + effectiveEnabledSkillIds: ['runner:agent-browser'], + }, + ], + }, + ], +}; + function makeClaudeAccount(): ClaudeAccountSummary { return { index: 0, @@ -197,6 +230,7 @@ function makeDeps( getModelConfig: () => modelConfig, getMoaSettings: () => moaSettings, getRuntimeInventory: () => runtimeInventory, + getRoomSkillSettings: () => roomSkillSettings, listClaudeAccounts: () => [makeClaudeAccount()], listCodexAccounts: () => [makeCodexAccount()], refreshAllCodexAccounts: async () => ({ refreshed: [1], failed: [] }), @@ -251,6 +285,13 @@ describe('web dashboard settings routes', () => { ejclaw: { runnerSkillDir: { count: 1 } }, }); + const roomSkills = await route('/api/settings/room-skills'); + expect(roomSkills?.status).toBe(200); + await expect(roomSkills?.json()).resolves.toMatchObject({ + catalog: [{ id: 'runner:agent-browser' }], + rooms: [{ jid: 'room@example', agents: [{ agentType: 'codex' }] }], + }); + const features = await route('/api/settings/codex-features'); expect(features?.status).toBe(200); await expect(features?.json()).resolves.toEqual(codexFeatures); diff --git a/src/web-dashboard-settings-routes.ts b/src/web-dashboard-settings-routes.ts index a4ca32e..0468153 100644 --- a/src/web-dashboard-settings-routes.ts +++ b/src/web-dashboard-settings-routes.ts @@ -23,6 +23,10 @@ import { getRuntimeInventory, type RuntimeInventorySnapshot, } from './runtime-inventory.js'; +import { + getRoomSkillSettings, + type RoomSkillSettingsSnapshot, +} from './room-skill-settings.js'; import { checkMoaModel, getMoaSettings, @@ -45,6 +49,7 @@ export interface SettingsRouteDependencies { getModelConfig: typeof getModelConfig; getMoaSettings: typeof getMoaSettings; getRuntimeInventory: typeof getRuntimeInventory; + getRoomSkillSettings: typeof getRoomSkillSettings; listClaudeAccounts: typeof listClaudeAccounts; listCodexAccounts: typeof listCodexAccounts; refreshAllCodexAccounts: typeof refreshAllCodexAccounts; @@ -73,6 +78,7 @@ const defaultSettingsRouteDependencies: SettingsRouteDependencies = { getModelConfig, getMoaSettings, getRuntimeInventory, + getRoomSkillSettings, listClaudeAccounts, listCodexAccounts, refreshAllCodexAccounts, @@ -222,6 +228,15 @@ function handleRuntimeInventoryRoute( return jsonResponse(deps.getRuntimeInventory()); } +function handleRoomSkillsRoute( + request: Request, + jsonResponse: JsonResponse, + deps: SettingsRouteDependencies, +): Response | null { + if (!readMethod(request.method)) return null; + return jsonResponse(deps.getRoomSkillSettings()); +} + async function handleClaudeAccountAddRoute( request: Request, jsonResponse: JsonResponse, @@ -338,6 +353,9 @@ export async function handleSettingsRoute({ if (url.pathname === '/api/settings/runtime-inventory') { return handleRuntimeInventoryRoute(request, jsonResponse, deps); } + if (url.pathname === '/api/settings/room-skills') { + return handleRoomSkillsRoute(request, jsonResponse, deps); + } if (url.pathname === '/api/settings/fast-mode') { return handleFastModeRoute(request, jsonResponse, deps); } @@ -385,4 +403,5 @@ export type { ModelConfigSnapshot, MoaSettingsSnapshot, RuntimeInventorySnapshot, + RoomSkillSettingsSnapshot, };