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.
This commit is contained in:
Eyejoker
2026-03-15 18:45:56 +09:00
parent 26783e520e
commit 6f4b6f8df7
5 changed files with 115 additions and 26 deletions

View File

@@ -4,7 +4,7 @@ Dual-agent AI assistant (Claude Code + Codex) over Discord. Based on [qwibitai/n
## Quick Context ## 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 ## 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/task-scheduler.ts` | Runs scheduled tasks |
| `src/db.ts` | SQLite operations | | `src/db.ts` | SQLite operations |
| `container/agent-runner/` | Claude Code runner (Agent SDK) | | `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) | | `groups/{name}/CLAUDE.md` | Per-group memory (isolated) |
## Skills ## Skills
@@ -54,19 +54,34 @@ Deploy to server: `scp dist/*.js clone-ej@100.64.185.108:~/nanoclaw/dist/`
## Dual-Service Architecture ## Dual-Service Architecture
- `nanoclaw.service` — Claude Code bot (`@claude`), uses `store/`, `data/`, `groups/` - `nanoclaw.service` — Claude Code bot (`@claude`), `SERVICE_ID=claude`, `SERVICE_AGENT_TYPE=claude-code`
- `nanoclaw-codex.service` — Codex bot (`@codex`), uses `store-codex/`, `data-codex/`, `groups-codex/` - `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 (`NANOCLAW_STORE_DIR`, etc.) - Both share the same codebase (`dist/index.js`), differentiated by env vars
- Channel registration is per-service DB (`registered_groups` table) - 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`): | 항목 | Claude (`nanoclaw`) | Codex (`nanoclaw-codex`) |
- `thread/start` / `thread/resume` for session persistence (threadId-based) |------|---------------------|--------------------------|
- `turn/start` for streaming responses (`item/agentMessage/delta`) | 서비스 로그 | `journalctl --user -u nanoclaw -f` | `journalctl --user -u nanoclaw-codex -f` |
- `turn/steer` for mid-execution message injection (IPC polling during turn) | 앱 로그 | `logs/nanoclaw.log` | `logs/nanoclaw-codex.log` |
- `approvalPolicy: "never"` + `sandbox: "danger-full-access"` for bypass | 그룹별 로그 | `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` - 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 ## Voice Transcription

View File

@@ -7,6 +7,11 @@ const envConfig = readEnvFile(['ASSISTANT_NAME', 'ASSISTANT_HAS_OWN_NUMBER']);
export const ASSISTANT_NAME = export const ASSISTANT_NAME =
process.env.ASSISTANT_NAME || envConfig.ASSISTANT_NAME || 'Andy'; 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 = export const ASSISTANT_HAS_OWN_NUMBER =
(process.env.ASSISTANT_HAS_OWN_NUMBER || (process.env.ASSISTANT_HAS_OWN_NUMBER ||
envConfig.ASSISTANT_HAS_OWN_NUMBER) === 'true'; envConfig.ASSISTANT_HAS_OWN_NUMBER) === 'true';

View File

@@ -2,7 +2,13 @@ import Database from 'better-sqlite3';
import fs from 'fs'; import fs from 'fs';
import path from 'path'; 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 { isValidGroupFolder } from './group-folder.js';
import { logger } from './logger.js'; import { logger } from './logger.js';
import { import {
@@ -70,8 +76,10 @@ function createSchema(database: Database.Database): void {
value TEXT NOT NULL value TEXT NOT NULL
); );
CREATE TABLE IF NOT EXISTS sessions ( CREATE TABLE IF NOT EXISTS sessions (
group_folder TEXT PRIMARY KEY, group_folder TEXT NOT NULL,
session_id 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 ( CREATE TABLE IF NOT EXISTS registered_groups (
jid TEXT PRIMARY KEY, jid TEXT PRIMARY KEY,
@@ -135,6 +143,31 @@ function createSchema(database: Database.Database): void {
/* column already exists */ /* 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) // Add channel and is_group columns if they don't exist (migration for existing DBs)
try { try {
database.exec(`ALTER TABLE chats ADD COLUMN channel TEXT`); database.exec(`ALTER TABLE chats ADD COLUMN channel TEXT`);
@@ -162,6 +195,8 @@ export function initDatabase(): void {
fs.mkdirSync(path.dirname(dbPath), { recursive: true }); fs.mkdirSync(path.dirname(dbPath), { recursive: true });
db = new Database(dbPath); db = new Database(dbPath);
db.pragma('journal_mode = WAL');
db.pragma('busy_timeout = 5000');
createSchema(db); createSchema(db);
// Migrate from JSON files if they exist // Migrate from JSON files if they exist
@@ -527,37 +562,59 @@ export function logTaskRun(log: TaskRunLog): void {
// --- Router state accessors --- // --- Router state accessors ---
export function getRouterState(key: string): string | undefined { export function getRouterState(key: string): string | undefined {
const prefixedKey = `${SERVICE_ID}:${key}`;
const row = db 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 = ?') .prepare('SELECT value FROM router_state WHERE key = ?')
.get(key) as { value: string } | undefined; .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 { export function setRouterState(key: string, value: string): void {
const prefixedKey = `${SERVICE_ID}:${key}`;
db.prepare( db.prepare(
'INSERT OR REPLACE INTO router_state (key, value) VALUES (?, ?)', 'INSERT OR REPLACE INTO router_state (key, value) VALUES (?, ?)',
).run(key, value); ).run(prefixedKey, value);
} }
// --- Session accessors --- // --- Session accessors ---
export function getSession(groupFolder: string): string | undefined { export function getSession(groupFolder: string): string | undefined {
const row = db const row = db
.prepare('SELECT session_id FROM sessions WHERE group_folder = ?') .prepare(
.get(groupFolder) as { session_id: string } | undefined; '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; return row?.session_id;
} }
export function setSession(groupFolder: string, sessionId: string): void { export function setSession(groupFolder: string, sessionId: string): void {
db.prepare( db.prepare(
'INSERT OR REPLACE INTO sessions (group_folder, session_id) VALUES (?, ?)', 'INSERT OR REPLACE INTO sessions (group_folder, agent_type, session_id) VALUES (?, ?, ?)',
).run(groupFolder, sessionId); ).run(groupFolder, SERVICE_AGENT_TYPE, sessionId);
} }
export function getAllSessions(): Record<string, string> { export function getAllSessions(): Record<string, string> {
const rows = db const rows = db
.prepare('SELECT group_folder, session_id FROM sessions') .prepare(
.all() as Array<{ group_folder: string; session_id: string }>; '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<string, string> = {}; const result: Record<string, string> = {};
for (const row of rows) { for (const row of rows) {
result[row.group_folder] = row.session_id; result[row.group_folder] = row.session_id;
@@ -632,8 +689,16 @@ export function setRegisteredGroup(jid: string, group: RegisteredGroup): void {
); );
} }
export function getAllRegisteredGroups(): Record<string, RegisteredGroup> { export function getAllRegisteredGroups(
const rows = db.prepare('SELECT * FROM registered_groups').all() as Array<{ agentTypeFilter?: string,
): Record<string, RegisteredGroup> {
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; jid: string;
name: string; name: string;
folder: string; folder: string;

View File

@@ -7,6 +7,7 @@ import {
ASSISTANT_NAME, ASSISTANT_NAME,
IDLE_TIMEOUT, IDLE_TIMEOUT,
POLL_INTERVAL, POLL_INTERVAL,
SERVICE_AGENT_TYPE,
STATUS_CHANNEL_ID, STATUS_CHANNEL_ID,
STATUS_UPDATE_INTERVAL, STATUS_UPDATE_INTERVAL,
TIMEZONE, TIMEZONE,
@@ -82,7 +83,7 @@ function loadState(): void {
lastAgentTimestamp = {}; lastAgentTimestamp = {};
} }
sessions = getAllSessions(); sessions = getAllSessions();
registeredGroups = getAllRegisteredGroups(); registeredGroups = getAllRegisteredGroups(SERVICE_AGENT_TYPE);
logger.info( logger.info(
{ groupCount: Object.keys(registeredGroups).length }, { groupCount: Object.keys(registeredGroups).length },
'State loaded', 'State loaded',

View File

@@ -1,7 +1,10 @@
import pino from 'pino'; import pino from 'pino';
const serviceName = (process.env.ASSISTANT_NAME || 'claude').toLowerCase();
export const logger = pino({ export const logger = pino({
level: process.env.LOG_LEVEL || 'info', level: process.env.LOG_LEVEL || 'info',
name: serviceName,
transport: { target: 'pino-pretty', options: { colorize: true } }, transport: { target: 'pino-pretty', options: { colorize: true } },
}); });