[codex] Add room skill settings inventory (#132)
Add read-only room skill settings inventory.\n\nNo production deploy/restart.
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<article className="runtime-agent-card">
|
||||
<header className="runtime-card-head">
|
||||
<h4>방별 스킬 정책</h4>
|
||||
</header>
|
||||
<p className="settings-hint">방별 스킬 정책을 불러오는 중…</p>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
const catalogById = new Map<string, RoomSkillCatalogItem>(
|
||||
snapshot.catalog.map((skill) => [skill.id, skill]),
|
||||
);
|
||||
const roomPreview = snapshot.rooms.slice(0, 8);
|
||||
|
||||
return (
|
||||
<article className="runtime-agent-card">
|
||||
<header className="runtime-card-head">
|
||||
<div>
|
||||
<h4>방별 스킬 정책</h4>
|
||||
<p className="settings-hint">
|
||||
전역 설정은 읽기 전용입니다. 이 목록은 다음 토글 PR에서 방 단위
|
||||
enable/disable 대상으로 쓰입니다.
|
||||
</p>
|
||||
</div>
|
||||
<span className="settings-account-badge is-active">
|
||||
{snapshot.catalog.length} skills · {snapshot.rooms.length} rooms
|
||||
</span>
|
||||
</header>
|
||||
|
||||
{roomPreview.length === 0 ? (
|
||||
<p className="settings-hint">등록된 방이 없습니다.</p>
|
||||
) : (
|
||||
<div className="runtime-room-skill-list">
|
||||
{roomPreview.map((room) => (
|
||||
<section className="runtime-room-skill-card" key={room.jid}>
|
||||
<header>
|
||||
<div>
|
||||
<strong>{room.name}</strong>
|
||||
<span>{room.folder}</span>
|
||||
</div>
|
||||
<code>{room.jid}</code>
|
||||
</header>
|
||||
<div className="runtime-room-agent-grid">
|
||||
{room.agents.map((agent) => {
|
||||
const disabledNames = agent.disabledSkillIds
|
||||
.map((id) => catalogById.get(id)?.displayName ?? id)
|
||||
.slice(0, 3);
|
||||
return (
|
||||
<div
|
||||
className="runtime-room-agent-policy"
|
||||
key={`${room.jid}:${agent.agentType}`}
|
||||
>
|
||||
<strong>{agentLabel(agent.agentType)}</strong>
|
||||
<span>
|
||||
{agent.mode === 'all-enabled'
|
||||
? '기본 전체 ON'
|
||||
: `${agent.disabledSkillIds.length}개 OFF`}
|
||||
</span>
|
||||
<small>
|
||||
사용 {agent.effectiveEnabledSkillIds.length} / 가능{' '}
|
||||
{agent.availableSkillIds.length}
|
||||
</small>
|
||||
{disabledNames.length > 0 ? (
|
||||
<small>OFF: {disabledNames.join(', ')}</small>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{snapshot.rooms.length > roomPreview.length ? (
|
||||
<small className="settings-hint">
|
||||
외 {snapshot.rooms.length - roomPreview.length}개 방 더 있음
|
||||
</small>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export function RuntimeInventorySettings() {
|
||||
const [snapshot, setSnapshot] = useState<RuntimeInventorySnapshot | null>(
|
||||
null,
|
||||
);
|
||||
const [roomSkillSnapshot, setRoomSkillSnapshot] =
|
||||
useState<RoomSkillSettingsSnapshot | null>(null);
|
||||
const [error, setError] = useState<string | null>(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() {
|
||||
|
||||
<AgentInventoryCard title="Codex" inventory={snapshot.codex} />
|
||||
<AgentInventoryCard title="Claude Code" inventory={snapshot.claude} />
|
||||
<RoomSkillPolicyCard snapshot={roomSkillSnapshot} />
|
||||
|
||||
<article className="runtime-agent-card">
|
||||
<header className="runtime-card-head">
|
||||
|
||||
@@ -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<RuntimeInventorySnapshot>
|
||||
return fetchJson('/api/settings/runtime-inventory');
|
||||
}
|
||||
|
||||
export async function fetchRoomSkillSettings(): Promise<RoomSkillSettingsSnapshot> {
|
||||
return fetchJson('/api/settings/room-skills');
|
||||
}
|
||||
|
||||
export async function updateCodexFeatures(
|
||||
input: Partial<CodexFeatureSnapshot>,
|
||||
): Promise<CodexFeatureSnapshot> {
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
if (!body) return {};
|
||||
const parsed = JSON.parse(body) as unknown;
|
||||
|
||||
@@ -62,6 +62,7 @@ export {
|
||||
getSessionForAgentType,
|
||||
getStoredRoomRoleAgentPlan,
|
||||
getStoredRoomSettings,
|
||||
getStoredRoomSkillOverrides,
|
||||
getTaskById,
|
||||
getTasksForGroup,
|
||||
hasActiveCiWatcherForChat,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
29
src/db/migrations/016_room-skill-overrides.ts
Normal file
29
src/db/migrations/016_room-skill-overrides.ts
Normal file
@@ -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);
|
||||
`);
|
||||
},
|
||||
};
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
195
src/room-skill-settings.test.ts
Normal file
195
src/room-skill-settings.test.ts
Normal file
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
239
src/room-skill-settings.ts
Normal file
239
src/room-skill-settings.ts
Normal file
@@ -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<string, RegisteredGroup>;
|
||||
registeredAgentTypesByJid?: Map<string, AgentType[]>;
|
||||
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<string, RoomSkillCatalogItem>,
|
||||
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<AgentType>([...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<string, RoomSkillCatalogItem>();
|
||||
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<string, AgentType[]>,
|
||||
): AgentType[] {
|
||||
const registered = registeredAgentTypesByJid?.get(jid) ?? [];
|
||||
const types = new Set<AgentType>(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(),
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user