0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【#10】 OpenClaw を読み解く — すべては SQLite に還る、状態の置き場所

0
Last updated at Posted at 2026-06-27

【#10】 OpenClaw を読み解く — すべては SQLite に還る、状態の置き場所

本記事のコード参照は OpenClaw maincee2aca409(version 2026.6.10)時点。行番号は更新でズレ得ます。

#01 で「状態は SQLite に集約」「Kysely で生 SQL を避ける」「マイグレーションは database-first」という3つの原則を予告しました。今回はその実装を src/state/src/plugin-state/src/infra/ から読み解きます。巨大アプリの「データの置き場所」をどう一貫させているか、です。

二つの器 — 共有とエージェント、状態の住み分け

OpenClaw のランタイム状態は2つの SQLite に分かれます。

共有状態 DB(グローバル状態 + プラグイン KV)。パスは src/state/openclaw-state-db.paths.ts:40

export function resolveOpenClawStateSqlitePath(env = process.env): string {
  return path.join(resolveOpenClawStateSqliteDir(env), "openclaw.sqlite");
}

実体は ~/.openclaw/state/openclaw.sqlite

エージェント単位 DB(エージェントスコープの状態/キャッシュ)。パスは src/state/openclaw-agent-db.paths.ts:20

export function resolveOpenClawAgentSqlitePath(options): string {
  const agentId = normalizeAgentId(options.agentId);
  return path.resolve(
    options.path ??
      path.join(
        path.dirname(resolveOpenClawStateSqliteDir(options.env ?? process.env)),
        "agents", agentId, "agent", "openclaw-agent.sqlite",
      ),
  );
}

実体は ~/.openclaw/agents/<agentId>/agent/openclaw-agent.sqlite

AGENTS.md の使い分けルールはこうです。

Use the shared state DB (state/openclaw.sqlite) for global runtime state and plugin KV data. Use the per-agent DB (agents/<agentId>/agent/openclaw-agent.sqlite) for agent-scoped state/cache. Use a dedicated SQLite DB only when schema, volume, or lifecycle clearly does not fit those stores.

「グローバルは共有 DB、エージェント固有はエージェント DB、どちらにも合わない場合だけ専用 DB」。新しいデータの置き場所を迷ったら、まずこの2つから選ぶ。これが状態の散逸を防ぐ単純で強い規律です。

ファイルは厳格なパーミッションで作られます(src/state/openclaw-state-db.ts:37)。

const OPENCLAW_STATE_DIR_MODE = 0o700;
const OPENCLAW_STATE_FILE_MODE = 0o600;

ディレクトリは 0700、ファイルは 0600。状態 DB は認証情報やセッションを含みうるので、所有者だけが読める権限に固定します。WAL/SHM/journal といったサイドカーの取り回しは、定常ランタイムではなく旧ストアの移行時にだけ必要になるため、マイグレーション側に集約されています(src/infra/state-migrations.tsPLUGIN_STATE_SQLITE_SIDECAR_SUFFIXES = ["", "-shm", "-wal", "-journal"] など、サイドカー4種を扱う)。

生 SQL を書かないという矜持 — Kysely の流儀

AGENTS.md の規約。

SQLite runtime access uses Kysely helpers, not raw SQL statement strings, except schema DDL, migrations, low-level DB bootstrap, or narrowly justified SQLite primitives.

Kysely は型安全なクエリビルダです。ヘルパは src/infra/kysely-sync.ts に集約されます。

export function getNodeSqliteKysely<Database>(db: DatabaseSync): Kysely<Database> {
  const existing = kyselyByDatabase.get(db);
  if (existing) return existing as Kysely<Database>;
  const kysely = new KyselyInstance<Database>({
    dialect: new CompileOnlyNodeSqliteKyselyDialect(),
  });
  kyselyByDatabase.set(db, kysely as Kysely<unknown>);
  return kysely;
}

export function executeSqliteQuerySync<Row>(db, query: CompilableQuery<Row>): QueryResult<Row> {
  return executeCompiledSqliteQuerySync<Row>(db, query.compile());
}

ポイントは CompileOnlyNodeSqliteKyselyDialect — Kysely をクエリのコンパイル(SQL 文字列 + パラメータの生成)にだけ使い、実行は Node 標準の node:sqliteDatabaseSync)で同期的に行う、というハイブリッドです。Kysely インスタンスは DB ごとにキャッシュされます。

代表的なクエリ(スキーマメタの upsert, src/state/openclaw-state-db.ts:835)。

const kysely = getNodeSqliteKysely<OpenClawStateMetadataDatabase>(db);
executeSqliteQuerySync(db,
  kysely.insertInto("schema_meta")
    .values({ meta_key: "primary", role: "global",
              schema_version: OPENCLAW_STATE_SCHEMA_VERSION, /* ... */ })
    .onConflict((c) => c.column("meta_key").doUpdateSet({ /* ... */ })));

型付きの insertInto(...).values(...).onConflict(...) で、UPSERT を文字列連結なしに書けます。SQL インジェクションの隙がなく、テーブル名・カラム名が型チェックされるのが効きどころです。

プラグインの持ち物も DB に — KV ストアという置き場

プラグインの永続データは共有 DB の plugin_state_entries テーブルに入ります(src/state/openclaw-state-schema.sql)。

CREATE TABLE IF NOT EXISTS plugin_state_entries (
  plugin_id TEXT NOT NULL,
  namespace TEXT NOT NULL,
  entry_key TEXT NOT NULL,
  value_json TEXT NOT NULL,
  created_at INTEGER NOT NULL,
  expires_at INTEGER,
  PRIMARY KEY (plugin_id, namespace, entry_key)
);

(plugin_id, namespace, entry_key) の複合主キーで、プラグインごと・名前空間ごとに分離。expires_at で TTL を持てます。操作(src/plugin-state/plugin-state-store.sqlite.ts)は register/upsert・lookup・consume(読んで即削除)・entries(一覧)・sweep(TTL 期限切れの掃除)。読み取りは TTL を考慮します。

.where("plugin_id", "=", params.pluginId)
.where("namespace", "=", params.namespace)
.where("entry_key", "=", params.key)
.where((eb) => eb.or([eb("expires_at", "is", null), eb("expires_at", ">", params.now)]))

「期限なし、または未来に期限」のものだけを返す。書き込みは runOpenClawStateWriteTransaction() で ACID 保証のトランザクション内に置かれます。プラグインは #04 の SDK 経由でこの KV を使い、自前のファイルを作りません——「プラグインのスクラッチデータも SQLite」という AGENTS.md の徹底です。

過去を引き受ける場所 — database-first と doctor

最も特徴的なのがマイグレーション哲学です。AGENTS.md:

State/storage migrations are database-first. Runtime reads/writes the canonical store only. Old file stores, sidecars, aliases, and fallback readers belong in openclaw doctor --fix migration code only, never steady-state runtime.

つまり、ランタイムは正準ストア(現行 SQLite)しか触らない。古いファイルストアやサイドカーを読むコードは、openclaw doctor --fix のマイグレーションコードにだけ存在する。定常ランタイムにフォールバックリーダーを置かない。

実例: レガシーなプラグイン状態サイドカー(plugin-state/state.sqlite)からの移行(src/infra/state-migrations.ts:352)。

function readLegacyPluginStateSidecarRows(sourcePath: string): LegacyPluginStateSidecarRow[] {
  const sqlite = requireNodeSqlite();
  const db = new sqlite.DatabaseSync(sourcePath, { readOnly: true });
  try {
    return db.prepare(`
      SELECT plugin_id, namespace, entry_key, value_json, created_at, expires_at
      FROM plugin_state_entries ORDER BY plugin_id ASC, namespace ASC, entry_key ASC
    `).all() as LegacyPluginStateSidecarRow[];
  } finally { db.close(); }
}

ここでは生 SQL を使っていることに注目してください。AGENTS.md が「マイグレーションは Kysely 例外」と明記しているとおり、レガシー読み取りは migration コードの特権です。読み取った行は Kysely 経由で共有 DB に onConflict(...).doNothing() で投入され、旧ファイルは .migrated サフィックスでアーカイブされます。

スキーマバージョンは OPENCLAW_STATE_SCHEMA_VERSIONsrc/state/openclaw-state-db.ts:34)で管理し、PRAGMA user_version に保存。新カラム追加は**加算的(additive)**で、ensureAdditiveStateColumns() が対応し、バージョン bump を伴いません。破壊的修復(複合主キーの是正など)は doctor ロジックに閉じ込められます。

なぜここまで厳しくするのか — 一本道がもたらす平穏

この章の規律は一見オーバーエンジニアリングに見えるかもしれません。が、#01 の「正準パスは一つ」と繋げると腑に落ちます。

  • ランタイムに「もし新形式が無ければ旧形式を読む」分岐を作らないから、状態読み込みコードが1本に保たれる。
  • 互換の責務を doctor に一極集中させるから、「いつ・どこで・何を移行したか」が追える。
  • すべてが SQLite だから、バックアップ・検査・移行が単一の仕組みで済む。

AGENTS.md の「Cache/transient state gets no compat migration ... Prefer delete/drop/rebuild over import.(キャッシュ・一時状態は互換移行せず、削除・再構築を優先)」という割り切りも、この哲学の延長です。失っても困らない状態は、移行せず捨てて作り直す。

まとめ — 正準ストアと、一極集中の互換

  • 状態は 二層 SQLite(共有 DB / エージェント DB)に集約。厳格なファイルパーミッション。
  • クエリは Kysely でコンパイル + node:sqlite で同期実行。生 SQL は DDL/マイグレーション等の例外のみ。
  • プラグイン KV は共有 DB の plugin_state_entries(複合主キー + TTL)。プラグインは自前ファイルを作らない。
  • マイグレーションは database-first: ランタイムは正準ストアのみ、互換は openclaw doctor --fix に一極集中。

次回予告 — 周縁にして重要な、メディアと外部プロトコルへ

#11 は、ここまで触れてこなかった周辺だが重要な領域、**メディア生成・理解と外部プロトコル(MCP / ACP)**です。画像・動画・音声の生成と理解をどう capability として束ねるか、そして MCP サーバ/クライアントと ACP(Agent Client Protocol)で外部ツール・外部エージェントとどう繋がるかを読み解きます。

図1.png

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?