From 6f4b6f8df77773e309ba52a98912a4bff6e6ab41 Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Sun, 15 Mar 2026 18:45:56 +0900 Subject: [PATCH] feat: prepare DB for shared access (WAL, service partitioning) Phase 0-2 of data unification: - Enable WAL mode + busy_timeout for safe concurrent access - router_state: keys prefixed with SERVICE_ID (lazy migration) - sessions: composite PK (group_folder, agent_type) - registered_groups: filtered by SERVICE_AGENT_TYPE on load - Logger: service name tag for unified log streams All changes are backward-compatible with the current split setup. Actual directory merge (Phase 3-4) is a separate step. --- CLAUDE.md | 39 +++++++++++++++------- src/config.ts | 5 +++ src/db.ts | 91 +++++++++++++++++++++++++++++++++++++++++++-------- src/index.ts | 3 +- src/logger.ts | 3 ++ 5 files changed, 115 insertions(+), 26 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 44c7ecc..6342cef 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ Dual-agent AI assistant (Claude Code + Codex) over Discord. Based on [qwibitai/n ## Quick Context -Two systemd services (`nanoclaw`, `nanoclaw-codex`) share the same codebase but run with separate stores, data, and groups. Agents run as direct host processes (no containers). Claude Code uses the Agent SDK; Codex uses `codex app-server` via JSON-RPC. Auth via `CLAUDE_CODE_OAUTH_TOKEN` in `.env` (1-year token from `claude setup-token`). +Two systemd services (`nanoclaw`, `nanoclaw-codex`) share the same codebase but run with separate stores, data, and groups (will be unified — DB supports shared access via WAL mode + service partitioning). Agents run as direct host processes (no containers). Claude Code uses the Agent SDK; Codex uses the Codex SDK (`codex exec`). Auth via `CLAUDE_CODE_OAUTH_TOKEN` in `.env` (1-year token from `claude setup-token`). ## Key Files @@ -19,7 +19,7 @@ Two systemd services (`nanoclaw`, `nanoclaw-codex`) share the same codebase but | `src/task-scheduler.ts` | Runs scheduled tasks | | `src/db.ts` | SQLite operations | | `container/agent-runner/` | Claude Code runner (Agent SDK) | -| `container/codex-runner/` | Codex runner (app-server JSON-RPC, streaming, turn/steer) | +| `container/codex-runner/` | Codex runner (SDK, `codex exec` wrapper) | | `groups/{name}/CLAUDE.md` | Per-group memory (isolated) | ## Skills @@ -54,19 +54,34 @@ Deploy to server: `scp dist/*.js clone-ej@100.64.185.108:~/nanoclaw/dist/` ## Dual-Service Architecture -- `nanoclaw.service` — Claude Code bot (`@claude`), uses `store/`, `data/`, `groups/` -- `nanoclaw-codex.service` — Codex bot (`@codex`), uses `store-codex/`, `data-codex/`, `groups-codex/` -- Both share the same codebase (`dist/index.js`), differentiated by env vars (`NANOCLAW_STORE_DIR`, etc.) -- Channel registration is per-service DB (`registered_groups` table) +- `nanoclaw.service` — Claude Code bot (`@claude`), `SERVICE_ID=claude`, `SERVICE_AGENT_TYPE=claude-code` +- `nanoclaw-codex.service` — Codex bot (`@codex`), `SERVICE_ID=codex`, `SERVICE_AGENT_TYPE=codex` +- Both share the same codebase (`dist/index.js`), differentiated by env vars +- Currently separate dirs (`store/` vs `store-codex/`), but DB supports shared access: + - `router_state`: keys prefixed with `{SERVICE_ID}:` (e.g., `claude:last_timestamp`) + - `sessions`: composite PK `(group_folder, agent_type)` + - `registered_groups`: filtered by `agent_type` on load + - SQLite WAL mode + `busy_timeout=5000` for concurrent access -## Codex App-Server +## Debugging Paths (Server: clone-ej@100.64.185.108) -Codex runner uses `codex app-server` JSON-RPC (not `codex exec`): -- `thread/start` / `thread/resume` for session persistence (threadId-based) -- `turn/start` for streaming responses (`item/agentMessage/delta`) -- `turn/steer` for mid-execution message injection (IPC polling during turn) -- `approvalPolicy: "never"` + `sandbox: "danger-full-access"` for bypass +| 항목 | Claude (`nanoclaw`) | Codex (`nanoclaw-codex`) | +|------|---------------------|--------------------------| +| 서비스 로그 | `journalctl --user -u nanoclaw -f` | `journalctl --user -u nanoclaw-codex -f` | +| 앱 로그 | `logs/nanoclaw.log` | `logs/nanoclaw-codex.log` | +| 그룹별 로그 | `groups/{name}/logs/` | `groups-codex/{name}/logs/` | +| DB | `store/messages.db` | `store-codex/messages.db` | +| 세션 | `data/sessions/{name}/.claude/` | `data-codex/sessions/{name}/.codex/` | +| 글로벌 설정 | `groups/global/CLAUDE.md` | `~/.codex/AGENTS.md` | + +## Codex SDK + +Codex runner uses `@openai/codex-sdk` (wraps `codex exec`): +- `codex.startThread()` / `codex.resumeThread()` for session persistence +- `thread.run(input)` for single-shot turn execution (completes all work before returning) +- `approvalPolicy: "never"` + `sandboxMode: "danger-full-access"` for bypass - Per-group: model (`CODEX_MODEL`), effort (`CODEX_EFFORT`), MCP servers via `config.toml` +- `CODEX_HOME` set to per-group session dir, reads `AGENTS.md` from there + CWD ## Voice Transcription diff --git a/src/config.ts b/src/config.ts index a3ae02e..7a2659b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -7,6 +7,11 @@ const envConfig = readEnvFile(['ASSISTANT_NAME', 'ASSISTANT_HAS_OWN_NUMBER']); export const ASSISTANT_NAME = process.env.ASSISTANT_NAME || envConfig.ASSISTANT_NAME || 'Andy'; +// Service identity for DB partitioning (shared DB, two services) +export const SERVICE_ID = + ASSISTANT_NAME.toLowerCase() === 'codex' ? 'codex' : 'claude'; +export const SERVICE_AGENT_TYPE: 'claude-code' | 'codex' = + ASSISTANT_NAME.toLowerCase() === 'codex' ? 'codex' : 'claude-code'; export const ASSISTANT_HAS_OWN_NUMBER = (process.env.ASSISTANT_HAS_OWN_NUMBER || envConfig.ASSISTANT_HAS_OWN_NUMBER) === 'true'; diff --git a/src/db.ts b/src/db.ts index 4973c8d..79934fb 100644 --- a/src/db.ts +++ b/src/db.ts @@ -2,7 +2,13 @@ import Database from 'better-sqlite3'; import fs from 'fs'; import path from 'path'; -import { ASSISTANT_NAME, DATA_DIR, STORE_DIR } from './config.js'; +import { + ASSISTANT_NAME, + DATA_DIR, + SERVICE_AGENT_TYPE, + SERVICE_ID, + STORE_DIR, +} from './config.js'; import { isValidGroupFolder } from './group-folder.js'; import { logger } from './logger.js'; import { @@ -70,8 +76,10 @@ function createSchema(database: Database.Database): void { value TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS sessions ( - group_folder TEXT PRIMARY KEY, - session_id TEXT NOT NULL + group_folder TEXT NOT NULL, + agent_type TEXT NOT NULL DEFAULT 'claude-code', + session_id TEXT NOT NULL, + PRIMARY KEY (group_folder, agent_type) ); CREATE TABLE IF NOT EXISTS registered_groups ( jid TEXT PRIMARY KEY, @@ -135,6 +143,31 @@ function createSchema(database: Database.Database): void { /* column already exists */ } + // Migrate sessions table to composite PK (group_folder, agent_type) + const sessionCols = database + .prepare('PRAGMA table_info(sessions)') + .all() as Array<{ name: string }>; + if (!sessionCols.some((col) => col.name === 'agent_type')) { + database.exec(` + CREATE TABLE sessions_new ( + group_folder TEXT NOT NULL, + agent_type TEXT NOT NULL DEFAULT 'claude-code', + session_id TEXT NOT NULL, + PRIMARY KEY (group_folder, agent_type) + ); + `); + database + .prepare( + `INSERT INTO sessions_new (group_folder, agent_type, session_id) + SELECT group_folder, ?, session_id FROM sessions`, + ) + .run(SERVICE_AGENT_TYPE); + database.exec(` + DROP TABLE sessions; + ALTER TABLE sessions_new RENAME TO sessions; + `); + } + // Add channel and is_group columns if they don't exist (migration for existing DBs) try { database.exec(`ALTER TABLE chats ADD COLUMN channel TEXT`); @@ -162,6 +195,8 @@ export function initDatabase(): void { fs.mkdirSync(path.dirname(dbPath), { recursive: true }); db = new Database(dbPath); + db.pragma('journal_mode = WAL'); + db.pragma('busy_timeout = 5000'); createSchema(db); // Migrate from JSON files if they exist @@ -527,37 +562,59 @@ export function logTaskRun(log: TaskRunLog): void { // --- Router state accessors --- export function getRouterState(key: string): string | undefined { + const prefixedKey = `${SERVICE_ID}:${key}`; const row = db + .prepare('SELECT value FROM router_state WHERE key = ?') + .get(prefixedKey) as { value: string } | undefined; + if (row) return row.value; + + // Lazy migration: read unprefixed key and migrate to prefixed + const old = db .prepare('SELECT value FROM router_state WHERE key = ?') .get(key) as { value: string } | undefined; - return row?.value; + if (old) { + db.prepare( + 'INSERT OR REPLACE INTO router_state (key, value) VALUES (?, ?)', + ).run(prefixedKey, old.value); + db.prepare('DELETE FROM router_state WHERE key = ?').run(key); + return old.value; + } + return undefined; } export function setRouterState(key: string, value: string): void { + const prefixedKey = `${SERVICE_ID}:${key}`; db.prepare( 'INSERT OR REPLACE INTO router_state (key, value) VALUES (?, ?)', - ).run(key, value); + ).run(prefixedKey, value); } // --- Session accessors --- export function getSession(groupFolder: string): string | undefined { const row = db - .prepare('SELECT session_id FROM sessions WHERE group_folder = ?') - .get(groupFolder) as { session_id: string } | undefined; + .prepare( + 'SELECT session_id FROM sessions WHERE group_folder = ? AND agent_type = ?', + ) + .get(groupFolder, SERVICE_AGENT_TYPE) as { session_id: string } | undefined; return row?.session_id; } export function setSession(groupFolder: string, sessionId: string): void { db.prepare( - 'INSERT OR REPLACE INTO sessions (group_folder, session_id) VALUES (?, ?)', - ).run(groupFolder, sessionId); + 'INSERT OR REPLACE INTO sessions (group_folder, agent_type, session_id) VALUES (?, ?, ?)', + ).run(groupFolder, SERVICE_AGENT_TYPE, sessionId); } export function getAllSessions(): Record { const rows = db - .prepare('SELECT group_folder, session_id FROM sessions') - .all() as Array<{ group_folder: string; session_id: string }>; + .prepare( + 'SELECT group_folder, session_id FROM sessions WHERE agent_type = ?', + ) + .all(SERVICE_AGENT_TYPE) as Array<{ + group_folder: string; + session_id: string; + }>; const result: Record = {}; for (const row of rows) { result[row.group_folder] = row.session_id; @@ -632,8 +689,16 @@ export function setRegisteredGroup(jid: string, group: RegisteredGroup): void { ); } -export function getAllRegisteredGroups(): Record { - const rows = db.prepare('SELECT * FROM registered_groups').all() as Array<{ +export function getAllRegisteredGroups( + agentTypeFilter?: string, +): Record { + const rows = ( + agentTypeFilter + ? db + .prepare('SELECT * FROM registered_groups WHERE agent_type = ?') + .all(agentTypeFilter) + : db.prepare('SELECT * FROM registered_groups').all() + ) as Array<{ jid: string; name: string; folder: string; diff --git a/src/index.ts b/src/index.ts index 4066ddc..d46af4b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,7 @@ import { ASSISTANT_NAME, IDLE_TIMEOUT, POLL_INTERVAL, + SERVICE_AGENT_TYPE, STATUS_CHANNEL_ID, STATUS_UPDATE_INTERVAL, TIMEZONE, @@ -82,7 +83,7 @@ function loadState(): void { lastAgentTimestamp = {}; } sessions = getAllSessions(); - registeredGroups = getAllRegisteredGroups(); + registeredGroups = getAllRegisteredGroups(SERVICE_AGENT_TYPE); logger.info( { groupCount: Object.keys(registeredGroups).length }, 'State loaded', diff --git a/src/logger.ts b/src/logger.ts index 273dc0f..7411010 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -1,7 +1,10 @@ import pino from 'pino'; +const serviceName = (process.env.ASSISTANT_NAME || 'claude').toLowerCase(); + export const logger = pino({ level: process.env.LOG_LEVEL || 'info', + name: serviceName, transport: { target: 'pino-pretty', options: { colorize: true } }, });