diff options
| author | tanaka takashi <isazaka@gmail.com> | 2026-07-04 18:44:17 +0900 |
|---|---|---|
| committer | tanaka takashi <isazaka@gmail.com> | 2026-07-04 18:44:17 +0900 |
| commit | 751999a82b8854ef5231e0ded5fe022b1ea5d8ff (patch) | |
| tree | d09653d2955f0f5354e9ef76f9bc550fb8f0732a | |
| parent | 21dda921887da2515d1eba0d3d26e2036bf9f7f7 (diff) | |
Harden backup import and restore coverage
| -rw-r--r-- | README.md | 28 | ||||
| -rw-r--r-- | docs/Design.md | 216 | ||||
| -rw-r--r-- | docs/Review.md | 217 | ||||
| -rw-r--r-- | docs/architecture.md | 375 | ||||
| -rw-r--r-- | docs/development-plan.md | 26 | ||||
| -rw-r--r-- | docs/implementation-status.md | 308 | ||||
| -rw-r--r-- | docs/review-and-next-plan.md | 149 | ||||
| -rw-r--r-- | docs/storage-io-budget.md | 20 | ||||
| -rw-r--r-- | src/cli/export.c | 48 | ||||
| -rw-r--r-- | src/cli/import.c | 580 | ||||
| -rw-r--r-- | src/domain/search.c | 20 | ||||
| -rw-r--r-- | src/domain/search.h | 1 | ||||
| -rw-r--r-- | src/mcp/tools/common/history.c | 19 | ||||
| -rw-r--r-- | src/mcp/tools/common/history.h | 1 | ||||
| -rw-r--r-- | src/mcp/tools/common/memory_index.c | 19 | ||||
| -rw-r--r-- | src/mcp/tools/common/memory_index.h | 1 | ||||
| -rw-r--r-- | src/mcp/tools/get_system_health.c | 19 | ||||
| -rw-r--r-- | src/storage/store.c | 23 | ||||
| -rw-r--r-- | src/storage/store.h | 3 | ||||
| -rw-r--r-- | tests/CMakeLists.txt | 11 | ||||
| -rw-r--r-- | tests/import_export_test.py | 120 | ||||
| -rw-r--r-- | tests/mcp_protocol_test.py | 570 | ||||
| -rw-r--r-- | tests/storage_test.c | 34 |
23 files changed, 1735 insertions, 1073 deletions
@@ -63,9 +63,8 @@ stdoutはMCP JSON-RPC専用、stderrは診断専用です。 - `get_system_health`: 永続化・復旧問題の診断に必要なStore状態と件数を取得 ツール結果はMCP TextContent内のJSON文字列です。入力schema、サイズ制限、エラーコード -および物理フォーマットは[architecture](docs/architecture.md)を参照してください。 -実装済み・未実装・テスト状況の追跡表は -[implementation status](docs/implementation-status.md)にまとめています。 +および物理フォーマットは[設計書](docs/Design.md)を参照してください。 +既知の課題、診断結果、次タスクは[レビューレポート](docs/Review.md)にまとめています。 ## 永続化と復旧 @@ -82,6 +81,29 @@ PUT/DELはsys層のdurability flushが成功するまで`OK`を返しません〠SSD、バックアップ等からの安全消去は保証しません。`memory_tier` は初期版では属性であり、 自動要約・昇格・破棄を行いません。 +## セキュリティとバックアップ + +data directory、snapshot、active log、JSONL exportはすべて平文であり、接続済みのstdio +hostは全tool権限を持ちます。CRC32は偶発的な破損検出用で、悪意ある改変の認証では +ありません。取得内容はprompt injectionを含み得るため、host側で命令として信頼しないでください。 + +data directoryとexport先は専用Windowsアカウントだけが読めるACLにします。 + +```powershell +icacls .\data /inheritance:r /grant:r "$env:USERNAME:(OI)(CI)F" +``` + +exportは元データと同じ機密区分で保管してください。暗号化が必要ならBitLocker等の上位暗号化 +filesystemを使用します。削除やcompactionはfilesystem、SSD、既存backupからのsecure eraseを +保証しません。 + +## 容量とメモリ予算 + +importは1行`1 MiB + 4096 bytes`、100,000 record、decode後合計4 GiBが上限です。 +`get_system_health.memory_estimate`はstore entry、本文、metadata、search、history、relation indexの +確保量概算を返します。allocator overhead等は含まないため、実working setには余裕を持たせて +ください。現行v1はlive corpusをメモリに保持し、retentionやdisk-backed bodyは実装しません。 + ## mcpcの管理 mcpc submoduleはコミット `4025fddfedcfa7573803a47236b147733fcb3c07` に固定しています。 diff --git a/docs/Design.md b/docs/Design.md new file mode 100644 index 0000000..3485a73 --- /dev/null +++ b/docs/Design.md @@ -0,0 +1,216 @@ +# Tiny ConvMem MCP Design + +## 1. 文書の位置づけ + +本書は `tiny-convmem-mcp` の設計、実装済み契約、制約、I/O 予算、実装状況をまとめた正本である。 +過去のプロトタイプ案と本書が矛盾する場合は、本書と公開ツールの実装を優先する。 + +診断結果と次タスクは [Review.md](Review.md) を参照する。 + +## 2. 目的とスコープ + +`tiny-convmem-mcp` は、会話メッセージをローカルに永続化し、MCP 経由で読み書き、更新、検索、削除できる単一プロセスの C17 サーバーである。 + +現行 v1 の対象は次のとおり。 + +- Windows x64 / MSVC +- stdio JSON-RPC / MCP +- 単一プロセス、単一 writer +- append-only バイナリログ、snapshot、同期 compaction +- インメモリ履歴・検索・relation index +- UTF-8 のメッセージと構造化 metadata +- CLI による JSONL import/export + +対象外は次のとおり。 + +- 複数 writer、ネットワークサービス、ユーザー認証 +- 保存時暗号化と secure physical erase +- retention、tier による自動移動・削除 +- 自動保存判断、自動要約、自動 consolidation +- 利用頻度による強化・減衰 +- search の cursor pagination + +## 3. アーキテクチャ + +依存方向は次を原則とする。 + +```text +MCP transport / tools -> domain contracts <- storage adapters + ^ + | + system API +``` + +- `src/app`: 引数処理、store/server/CLI のライフサイクル +- `src/mcp`: MCP 境界、入力検証、JSON 応答、tool registry +- `src/domain`: Message、metadata、検索契約 +- `src/storage`: log、snapshot、replay、compaction、import transaction +- `src/sys`: path、lock、file、flush、clock、random、UTF-8 境界 +- `src/cli`: offline import/export +- `third_party/mcpc`: MCP transport と JSON parser + +永続化ロジックは `src/storage`、OS 固有処理は `src/sys` に閉じ込める。read 系 tool は永続化状態を変更しない。 + +## 4. ドメインモデル + +Message は次の不変 identity と、追記レコードで更新される内容を持つ。 + +```json +{ + "message_id": "26-character ULID", + "session_id": "session", + "timestamp": 1720843200000, + "updated_at": 1720843200000, + "revision": 1, + "role": "user | assistant | system", + "content": "UTF-8 text", + "metadata": { + "tool_call_id": "optional", + "tags": ["optional"], + "memory_tier": "short | mid | long", + "memory_kind": "conversation | episode | semantic | procedure | preference | summary", + "importance": 0, + "summary": "optional", + "relations": [{"type": "derived_from", "message_id": "..."}], + "source_message_ids": ["..."] + } +} +``` + +主な上限は content 1 MiB、session/tool-call ID 256 bytes、tag 128 bytes × 32、summary 4096 bytes、relation 32、summary source 64 である。文字列は有効な UTF-8 を要求する。 + +`memory_tier` は現時点では分類ラベルであり、保存場所や寿命を自動変更しない。`summary`、relation、source ID は host が行う consolidation を表現するための契約である。 + +UPDATE は message ID、session、role、作成時刻を維持する。revision による optimistic concurrency を使い、変更のない update はレコードを書かない。 + +## 5. 公開 MCP ツール + +### `write_message` + +session、role、content と任意 metadata を受け取り、ULID と UTC millisecond timestamp を生成する。ログ書込みと durability flush が成功した後だけ成功を返す。 + +### `update_message` + +content または metadata を部分更新する。`expected_revision` が一致しない場合は conflict とする。delta record には変更 field だけを書き、no-op は 0 write / 0 flush とする。 + +### `read_messages` + +次の view を持つ。 + +- `history`: session の時系列ページ。安定 cursor を返す +- `context`: target の前後メッセージと incoming/outgoing relation を返す。relation depth は 0~2 +- `sessions`: session ごとの件数、kind 集計、代表 summary、最終時刻を返す +- `export`: versioned JSONL をページ出力する互換 view + +レスポンスは 8 MiB budget を超えない位置でページを区切る。 + +### `search_messages` + +content、summary、tags を対象に検索する。session、kind、tier、tag、minimum importance で絞り込める。matched fields、score と field 別 score 内訳、任意の前後 context を返す。 + +検索 index は ASCII case fold 後の Unicode codepoint 2/3-gram を用いる。hash candidate は元 codepoint と照合し、完全 substring を優先する。importance は relevance score ではなく tie-break に用いる。検索はアクセス履歴を保存しない。 + +### `delete_message` + +DEL tombstone を durability flush し、対象を直ちに read/search から除外する。物理削除は compaction 後である。任意の session guard を指定できる。 + +### `rebuild_index` + +live message だけで active log を再生成し、snapshot と全インメモリ index を再構築する。dangling relation/source は compaction 前に除去する。同期処理であり、実行中は他の request を処理しない。 + +### `get_system_health` + +live/deleted count、sequence、log/snapshot size、snapshot/recovery 状態、論理 I/O counter、last error を返す。 +store body/metadataと各in-memory indexの確保capacityに基づくmemory estimateも返す。 + +## 6. ストレージと復旧 + +### 6.1 Active log + +各 record は magic、format version、record type、payload length、sequence、payload CRC32 と payload を持つ。PUT、UPDATE delta、DEL、generation marker を扱う。 + +- record length は上限を検証する +- sequence は単調増加を要求する +- complete record の CRC、version、type 異常は起動失敗とする +- incomplete final record だけは最後の正常 offset へ truncate して復旧する +- 不正な complete record を黙って skip しない + +### 6.2 Snapshot + +snapshot は live Message、log generation、replay offset を保持する。一時ファイルへ書き、flush 後に原子的に置換する。 + +起動時は generation が active log と一致し、CRC と offset が妥当な snapshot だけを採用し、その後の log tail を replay する。不正 snapshot は捨て、active log の先頭から再生する。 + +### 6.3 Compaction + +rebuild は live Message を temporary log に書き、flush、replace した後に snapshot を作る。DEL と古い UPDATE/PUT payload を物理的に除去する。log と snapshot は別ファイルのため置換間に crash window があるが、generation 不一致時は log 全 replay へ fallback する設計である。 + +### 6.4 排他 + +`--data-dir` は必須である。`store.lock` を non-blocking exclusive lock し、同じ data directory を複数プロセスが同時に開くことを拒否する。 + +## 7. JSONL import/export + +CLI export は先頭に format manifest を出し、live record と任意の DEL を UTF-8 JSONL として出力する。CLI import は format version、全 field、参照整合性を検証し、`overwrite` または `reject` policy で transaction 的に適用する。MCP サーバー起動中の offline import/export は lock により拒否される。 + +importはJSONLを行単位で読み、JSON stringをUTF-8へ完全decodeしてからfield上限を検証する。 +整数は小数・指数表記を許可せず、timestamp/updated_atは0~INT64_MAX、revisionは +1~INT32_MAX、importanceは0~100とする。1行はcontent上限+4096 bytes、100,000 record、 +decode後合計4 GiBが上限である。file exportはtemporary fileのflush/close成功後だけ原子的に置換する。 + +## 8. I/O 予算 + +成功応答前の durability flush を維持し、sys 境界の logical counter を合否判定に使う。 + +| 操作 | 最大 logical write | 最大 flush | +|---|---:|---:| +| write | header + serialized Message | 1 | +| metadata-only update | header + changed fields | 1 | +| content update | header + content + changed fields | 1 | +| no-op update | 0 | 0 | +| delete | fixed tombstone | 1 | +| read/search/context/export/sessions | 0 | 0 | +| no-op rebuild | 0 | 0 | +| rebuild | live serialized bytes × 2 + bounded headers | log 1 + snapshot 1 | + +Release build で 20 B~1 MiB payload と 10,000 / 50,000 record corpus を測定し、根拠のない 10% 以上の回帰を許容しない。 + +## 9. セキュリティと運用上の境界 + +- stdio 接続済み host は全 Message の読取り、追加、更新、削除、maintenance が可能である +- data at rest は暗号化されず、data directory の OS ACL に依存する +- CRC32 は破損検出用であり、悪意ある改変への認証ではない +- content や metadata は prompt injection を含み得る。取得内容を命令として信頼するかは host 側の責任である +- export は平文バックアップであり、元データと同等に保護する +- secure erase は保証しない。削除後も compaction、backup、filesystem 履歴に残り得る + +## 10. フィードバックの反映状況 + +利用者フィードバックの優先項目に対する設計上の対応は次のとおり。 + +| 要望 | 状況 | 実装 | +|---|---|---| +| バックアップ・export | 部分達成 | JSONL CLI と export view。import 完全性に既知課題あり | +| 保存後の更新 | 達成 | delta update、identity 維持、revision conflict | +| 記憶の種類分け | 達成 | 6 kinds と検索 filter | +| 2段階保存 | 部分達成 | summary/source/relation 契約。自動 consolidation は host 責務 | +| 検索結果の文脈 | 達成 | score 説明、前後 context、relation traversal | +| session 一覧 | 達成 | sessions view、kind 集計、代表 summary | +| metadata 活用 | 達成 | tag/tier/kind/importance の更新・filter | +| 自動保存判断 | 未実装 | host policy の範囲 | +| 利用による強化・減衰 | 未実装 | read は 0 write のため access state を保持しない | + +## 11. 実装・検証状況 + +構造化 metadata、UPDATE、relation index、検索説明、context、sessions、CLI import/export、再起動時の dangling reference 処理まで実装済みである。 + +継続的に再検証する項目は次のとおり。 + +- MCP protocol E2Eと有効snapshot + PUT/DEL tail replay +- snapshot temporary file 各段階の強制終了試験 +- compaction の flush/replace 前後と log/snapshot 間 crash matrix +- importの1 MiB escape round-trip、数値境界、streaming上限 +- live corpus増加時のmemory benchmark(retentionはv1対象外) +- data directory ACLとbackup保護の運用文書 + +具体的な優先順位と完了条件は [Review.md](Review.md) に記載する。 diff --git a/docs/Review.md b/docs/Review.md new file mode 100644 index 0000000..b4d351a --- /dev/null +++ b/docs/Review.md @@ -0,0 +1,217 @@ +# Critical Review and Next Tasks + +## 1. 結論 + +現行実装は append-only log、durability flush、CRC/sequence 検証、snapshot fallback、単一 writer lock、更新 revision、検索説明性という基盤がよく整理されている。明白な恒久メモリリークは静的確認と実行可能だった ASan test では確認されなかった。 + +レビューで指摘したR1~R7は対応済みである。protocol E2Eを復旧し、importの完全decode、厳密整数、 +bounded line streaming、atomic export、memory estimate、セキュリティ運用記載を追加した。 + +## 2. 診断条件 + +- 比較基準: commit `1efecaef1f867cb218366e2b4ab4b901fdd2835f` 以降 +- 対象: domain、storage、MCP tools、CLI、system abstraction、tests、docs +- 実行: Windows VS2026 Debug と ASan の build/CTest +- 対応後結果: Debug、Release、ASanはいずれも17/17 pass + +復旧したprotocol workflowは各構成で検索1,000回、MCP lifecycle、rebuild、restartまで成功した。 + +Release import/export benchmark(2026-07-04、同一開発機)の結果は次のとおり。 + +| records | import | export | round-trip import | +|---:|---:|---:|---:| +| 10,000 | 0.277 s | 0.165 s | 0.284 s | +| 50,000 | 4.668 s | 4.226 s | 4.794 s | +| 100,000 | 19.725 s | 18.465 s | 19.644 s | + +既存のMCP workload計測では10,000件が18,960,384 bytes、50,000件が51,240,960 bytesの +peak working setだった。100,000件はCLI上限とround-tripを確認し、実プロセスの継続監視には +`get_system_health.memory_estimate`とOS working setを併用する。 + +## 3. Findings + +以下は発見時の記録である。R1~R4、R7は実装と回帰試験で解消し、R5はhealth内訳と運用上限を +追加、R6はthreat modelとACL推奨を文書化した。disk-backed化、retention、組込み暗号化は +引き続きv1の対象外である。 + +### R1 — High: 長い JSON content の import が内容を変える + +`src/cli/import.c` の `import_parse_message_record` は content を固定 4096-byte buffer で decode する。収まらない場合は JSON token の quote 内部を raw copy するため、`\\n`、`\\"`、`\\\\`、`\\uXXXX` が元の文字へ decode されない。 + +影響: + +- export → import round-trip で Message content が変化する +- backup restore の完全性を保証できない +- raw escape が検索結果や host prompt に異なる意味で現れる + +修正: + +1. `mjson_get_string` の必要長を使って動的 buffer を確保する helper を用意する +2. session、summary、tag 等を含む全 JSON string を同じ helper に統一する +3. decode 後の byte length と UTF-8 を検証する + +完了条件: + +- 4 KiB未満・超、1 MiB境界で escape 4種を含む export/import byte-exact round-trip +- invalid escape、decoded overflow、embedded NUL を拒否 +- ASan/Debug/Release success + +### R2 — High: protocol E2E test が削除されたまま CTest に登録されている + +commit `2861118` で `tests/mcp_protocol_test.py` が削除されたが、`tests/CMakeLists.txt` は引き続き登録している。実装状況文書もこの test が検索1000回、context/session、delete/rebuild/restart を検証すると記載していた。 + +影響: + +- clean checkout で test suite が必ず failure +- MCP schema、workflow、response budget、restart regression が未検証 +- ASan の memory safety 評価範囲が縮小 + +修正: + +- test を履歴から復旧し、現行 tool description/contract に合わせる +- 一時的に CTest 登録だけ消して green に見せない +- test source の存在を configure 時に検証する + +完了条件: + +- clean checkout の Debug/Release/ASan が全件成功 +- write → update → history/context/sessions → repeated search → delete → rebuild → restart を通す + +### R3 — Medium: import size guard が約1 TiBで全体を一括確保する + +`IMPORT_MAX_LINE * 1024 * 1024` は約1 TiBとなる。実際には `ftell` と address space が先に制限するが、入力ファイル全体を `malloc` し、さらに parse 済み Message を保持する。 + +影響: + +- 大きな import による process memory exhaustion +- 通常 corpus でも raw JSONL と parsed records の同時保持による高い peak memory + +修正: + +- manifest と record を行単位で streaming parse する +- 1行上限、総 record 数、総 decoded bytes の明示上限を設ける +- transaction 要件は temporary store/log を使って維持する + +完了条件: + +- 上限超過を allocation 前に拒否 +- 50,000 record import の peak memory を計測・記録 +- truncated line と極端な長行を安全に拒否 + +### R4 — Medium: JSON number を `double` から直接整数変換している + +timestamp、updated_at、revision、importance は `mjson_get_number` の `double` を直接整数へ cast する。finite、整数性、変換前範囲を十分に検証していない。 + +影響: + +- 2^53超で精度を失う +- fractional importance/revision を暗黙に切り捨てる +- NaN、Infinity、整数型範囲外で未定義または処理系依存の変換になり得る + +修正: + +- JSON token を整数文字列として厳密 parse する、または finite/range/floor 検証後に cast する +- timestamp、revision、importance ごとに許容範囲を定義する + +完了条件: + +- min/max、max±1、fraction、exponent、NaN相当、Infinity相当の table-driven test +- export した全有効値が exact round-trip + +### R5 — Medium: 正常なデータ増加がプロセスメモリを増やし続ける + +これは解放忘れではなく設計上の resident growth である。live Message content に加えて、history、search posting、relation index をメモリに保持する。retention と tier lifecycle は未実装である。 + +影響: + +- 長期運用では live corpus に比例して working set が増える +- compaction は dead record を減らすが live Message は減らさない +- 1 MiB content が多数ある場合に早く顕在化する + +修正候補: + +- 想定最大 corpus と memory budget を先に定義する +- Message body の disk-backed/lazy load、index圧縮、retention のいずれかを選択する +- health に index/body memory estimate を追加する + +完了条件: + +- 10k/50k/100k件と large-content corpus の peak/steady memory benchmark +- rebuild と検索反復後に基準へ戻ることを確認 + +### R6 — Medium: data at rest の機密性・改変耐性は OS ACL 任せ + +log、snapshot、JSONL backup は平文である。CRC32 は偶発破損検出であり、悪意ある改変の認証ではない。stdio host は接続後、全 tool 権限を持つ。 + +修正: + +- threat model と data directory ACL の推奨設定を README に記載する +- export file を同等の機密データとして扱うことを明記する +- 暗号化が必要な deployment は上位暗号化 filesystem または別設計とする +- 将来暗号化する場合は鍵管理、nonce、version migration を先に設計する + +### R7 — Low: export の close/flush error を成功扱いし得る + +record 書込み中の `fprintf` failure は検出するが、最終 `fclose` の failure を確認していない。遅延 write error や媒体容量不足が close 時に判明した場合、成功 exit となり得る。 + +修正: + +- `fflush` と `fclose` の戻り値を検査する +- backup file を temporary path に書き、成功後に replace する option を検討する + +## 4. 設計適合性 + +現行の authoritative contract に対する適合度は概ね高い。 + +適合している主な点: + +- MCP/domain/storage/sys の責務分離 +- append-only PUT/UPDATE/DEL と optimistic revision +- 成功応答前 durability flush +- CRC、sequence、length、version の fail-closed validation +- incomplete tail の限定的 recovery +- snapshot generation/offset validation と full replay fallback +- exclusive data-directory lock +- read 系 0 write / 0 flush +- response size budget と安定 cursor + +検証不足: + +- valid snapshot + tail replay の専用 test +- compaction の log replace と snapshot replace 間の crash +- replace/flush failure matrix +- protocol E2E と長時間 ASan workflow + +## 5. フィードバック反映度 + +優先7項目は5件達成、2件部分達成で、およそ80%を反映している。 + +- 達成: update、kind、context、sessions、metadata filter/update +- 部分達成: backupは実装済みだがR1あり、2段階保存は表現契約のみ +- 未実装の発展要望: 自動保存、自動要約・固定化、access reinforcement、decay + +構造化と検索経路は大きく改善した一方、「利用と時間によって状態が変わる生物的記憶」は意図的に host policy のままである。 + +## 6. 推奨実装順序 + +| 順位 | Task | 依存 | 完了判定 | +|---:|---|---|---| +| 1 | R2 protocol E2E test 復旧 | なし | clean checkout 全構成 green | +| 2 | R1 JSON string decode 修正 | 1 | 1 MiB escape round-trip | +| 3 | R4 strict integer parser | 2 | 境界値 test green | +| 4 | R3 streaming import | 2,3 | 上限・rollback・peak memory 合格 | +| 5 | snapshot/compaction crash matrix | 1 | 全停止点から一貫して復旧 | +| 6 | R7 export completion検査 | なし | close failure test | +| 7 | R5 memory budget設計 | benchmark後 | 最大 corpus と方式を決定 | +| 8 | R6 security運用文書 | なし | threat model/ACL/backup記載 | + +## 7. Release gate + +次を満たすまで backup/import を安定版として扱わない。 + +- Debug、Release、ASan の CTest が clean checkout で全件成功 +- 1 MiB content の export/import exact round-trip +- strict numeric boundary test 成功 +- import 上限超過時に bounded memory で安全に失敗 +- valid snapshot tail replay と compaction crash recovery 成功 +- 50,000 record benchmark に I/O、時間、peak memory を記録 diff --git a/docs/architecture.md b/docs/architecture.md deleted file mode 100644 index 04e2489..0000000 --- a/docs/architecture.md +++ /dev/null @@ -1,375 +0,0 @@ -# Architecture - -設計項目ごとの実装・テスト状況は -[設計・実装・テスト状況一覧](implementation-status.md)を参照してください。 - -`tiny-convmem-mcp` is a single-process C17 MCP server backed by a -durable append-only conversation log. The executable owns the stdio dispatch -boundary and uses mcpc's bundled mjson parser through a local compatibility -layer. - -Conversation-memory work is split into these dependencies: - -```text -MCP transport and tools -> domain contracts <- storage adapters -``` - -- `src/mcp` translates MCP requests and responses. It may depend on mcpc and - domain contracts, but it must not contain persistence logic. -- `src/domain` owns conversation and message concepts without depending on - mcpc or a database. -- `src/storage` implements the versioned binary log, recovery, snapshot, and - compaction through the platform-neutral `src/sys` API. -- `src/sys` owns UTF-8 path conversion, files, process locks, durable flush, - clocks, and random bytes. Its implementation is the only platform split. - -## Implemented prototype contract (v1) - -The seven public tools are `write_message`, `update_message`, `read_messages`, -`search_messages`, `delete_message`, `rebuild_index`, and `get_system_health`. The detailed -"ProtoType Architecture v0.0.1" below is retained as the original proposal; -where it conflicts with this section, the implemented contract is authoritative. - -- `write_message` creates the ULID and UTC millisecond timestamp. A PUT or DEL - succeeds only after the platform durability flush succeeds. -- Deletion is immediately invisible but remains a tombstone until synchronous - compaction. `metadata.deleted` is not part of immutable Message data. -- Metadata supports optional `tool_call_id`, `tags`, `memory_tier`, memory kind, - importance, summary, typed relations, and summary source IDs. - Tiers remain labels; no automatic lifecycle operation is performed. -- `read_messages` defaults to the compatible chronological history view and also - exposes sessions, context, and versioned JSONL export views. -- UPDATE records use optimistic revisions and delta fields. A no-op update does - not write or flush storage. -- Search uses ASCII-case-folded Unicode codepoint 2/3-grams, verifies hash - matches against the original codepoints, and ranks exact substrings first. -- A log record contains magic, format version, type, payload length, sequence, - and payload CRC32. PUT, DEL, and generation marker records are supported. -- A valid snapshot contains immutable messages and a log generation/offset. - Startup loads it only when the active log generation matches, then replays the - tail. Otherwise startup performs a full log replay. -- An incomplete final record is truncated. Unknown versions/types, invalid - lengths, non-monotonic sequences, or CRC errors within a complete record stop - startup without silently skipping data. -- `--data-dir <path>` is mandatory and guarded by a process-exclusive lock. - -The prototype remains Windows x64/MSVC and single-process. Retention, -encryption, search pagination, multiple writers, automated summarization, and secure -physical erasure are outside v1. - -# ProtoType Architecture v0.0.1 -Tiny-ConvMem-MCP: FINAL TOOL INTERFACE SPECIFICATION (v1.0) - -この定義は、システムと外部(LLMプロセス)との間の「契約書」となります。これで実装に着手できますね! -No. Tool Name カテゴリ 機能概要 入力引数 (Arguments) 出力値 (Response) -1 write_message SET/WRITE 新しいメッセージイベントをログファイルへ追記し、インデックスを更新する。 session_id, role, content, timestamp status: Enum(OK, ERROR) -2 read_messages GET/READ 特定セッションIDのメッセージを安定順序でページ取得する。 session_id、limit、cursor history、nextCursor -3 search_messages GET/READ クエリに基づいてログファイルから関連するメッセージを特定し、返す。(インデックス利用が前提) query, session_id (任意), limit: 整数 results: 配列(メッセージオブジェクト) -4 delete_message SET/MANAGE 特定のメッセージIDを指定してログから完全に削除し、インデックスをクリーンアップする。 message_id: 唯一ID, session_id (任意) status: Enum(OK, NOT_FOUND) -5 rebuild_index SET/MANAGE インデックスファイルを再生成し、データ整合性を回復させる。(メンテナンス) なし status: Enum(OK, ERROR) -6 get_system_health GET/READ システムの稼働状況とログファイルの健全性レポートを提供する。 なし report: JSON (ヘルスステータス) - - -データ構造設計:メッセージオブジェクト (Message Object Definition) -🎯 基本方針 - -全てのデータは**「不変(Immutable)」**であることを原則とします。一度記録されたイベントは変更せず、削除が必要な場合は新しいエントリで処理するか、専用の delete_message コマンドで対応します。 -📝 メッセージオブジェクト (Message) の構造定義案 - -メッセージを構成する要素を分解し、データ型を明確に定義します。(C++でのStructやJSONスキーマとして使用できます) - -{ - "message_id": "UUID/ULID文字列", // ★最重要。削除・参照のユニークキー - "session_id": "ユーザー固有のセッション識別子", - "timestamp": 1720843200000, // UTCミリ秒タイムスタンプ (UTC厳守) - "role": "ENUM (user | assistant | system)", // メッセージの発信元 - "content": "メッセージの本文(テキストデータ)", // 記録したい主要な情報 - "metadata": { // 追加情報格納用。拡張性を持たせるための辞書型 - "tool_call_id": "もしこのメッセージがツール呼び出しの結果ならIDを保持", - "is_system_message": true/false, // システム操作やプロンプト定義など - "checksum": "(オプション)データの完全性を保証するためのハッシュ値" - } -} - -⚙️ 補足的なデータ構造の定義 - -ログシステムをより堅牢にするために、メッセージとは別に必要な型も定義しておくと完璧です。 - - LogEntry (永続化層の最小単位): - 実際にディスクに書き込まれる最も小さいデータ単位。(上記 Message のバイナリ/JSON表現) - SearchIndexEntry (インデックス構造体): - 検索効率を最大化するための、非常に軽量なメタデータ。 - message_id: どのメッセージかを示すキー - session_id: 属するセッションID - keywords_hash: メッセージ内容から抽出されたキーワードのハッシュ値(またはリスト) - file_offset: ログファイル内での物理的な開始位置 - data_size: そのメッセージが占めるバイトサイズ - -📐 Tiny-ConMem-MCP: ストレージ制御 統合設計仕様 -1. アーキテクチャ概観 - -┌─────────────────────────────────────────────────────────────┐ -│ 外部プロセス (LLM Host) │ -└──────────────────────────┬──────────────────────────────────┘ - │ stdio (JSON-RPC) -┌──────────────────────────▼──────────────────────────────────┐ -│ L1: Protocol Layer (mcpc) │ -│ MCPメッセージの受信・ツールディスパッチ │ -└──────────────────────────┬──────────────────────────────────┘ - │ -┌──────────────────────────▼──────────────────────────────────┐ -│ L2: Memory Core (ビジネスロジック) │ -│ │ -│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ -│ │ Write Queue │ │ In-Memory │ │ Search │ │ -│ │ (保留中キュー) │ │ Index │ │ Engine │ │ -│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │ -│ │ │ │ │ -└─────────┼────────────────┼────────────────┼──────────────────┘ - │ │ │ -┌─────────▼────────────────▼────────────────▼──────────────────┐ -│ L3: Persistence Layer (永続化層) │ -│ │ -│ ┌──────────┐ ┌────────────┐ ┌───────────────────┐ │ -│ │ active.log│ │snapshot.bin│ │ compaction worker │ │ -│ │(append- │ │(状態保存) │ │(ログ整理・容量削減) │ │ -│ │ only) │ └────────────┘ └───────────────────┘ │ -│ └──────────┘ │ -└─────────────────────────────────────────────────────────────┘ - -2. データ構造定義 -2.1 Message Object(永続化対象の基本単位) - -全ツールの入力・出力の中心となるデータです。一度書き込まれたら不変(Immutable)で、変更は新エントリの追記で表現します。 - -// Message (ログ1レコードの論理構造) -{ - "message_id": "ULID文字列", // 削除・参照のユニークキー - "session_id": "セッション識別子", // セッション単位のグルーピング - "timestamp": 1720843200000, // UTC ミリ秒 - "role": "user | assistant | system", - "content": "メッセージ本文", - "metadata": { - "tool_call_id": "(任意)ツール呼び出しID", - "tags": ["カレー", "設計"], //(任意)検索用タグ - "memory_tier": "short | mid | long", // 後述のメモリ階層フラグ - "deleted": false // 論理削除フラグ - } -} - -2.2 LogRecord(ディスク上の物理フォーマット) - -active.log に追記される物理レコードです。Message Object をシリアライズしたものに、検証用のメタ情報を付加します。 - -┌──────────┬──────────┬─────────────────────┬──────────┐ -│ record │ payload │ payload (serialized │ checksum │ -│ length │ type │ Message Object) │ (CRC32) │ -│ (4 byte) │ (1 byte) │ (可変長) │ (4 byte) │ -└──────────┴──────────┴─────────────────────┴──────────┘ - -payload_type: - 0x01 = PUT (メッセージの書き込み) - 0x02 = DEL (メッセージの論理削除) - 0x03 = SNAPSHOT_MARKER (snapshot境界マーカー) - -2.3 In-Memory Index(メモリ上のインデックス) - -メモリ上に保持する高速検索用の目次です。これが SearchIndexEntry の実体で、search_messages / read_messages の高速化を担います。 - -struct IndexEntry { - uint64_t file_id; // ログファイル識別子 (1 = active.log 等) - uint64_t offset; // レコードの開始バイト位置 - uint32_t length; // レコード長 - int64_t timestamp; // メッセージのタイムスタンプ - uint8_t type; // PUT / DEL - // セッションID・メッセージID は - // ハッシュテーブルの Key として使用 -}; - -// メモリ上のデータ構造(ハッシュテーブル + リスト) -HashTable<message_id, IndexEntry*> // message_id → 位置情報 -HashTable<session_id, List<message_id>> // session_id → 所属メッセージ一覧 -HashTable<keyword_hash, List<message_id>> // 検索用キーワード逆引き - -3. 書き込みパイプライン(write_message / delete_message) - -┌─────────────┐ -│ MCP Tool 呼出 │ write_message / delete_message -└──────┬──────┘ - │ - ▼ -┌─────────────────────┐ -│ Step 1: メモリ更新 │ In-Memory Index に即座に反映 -│ │ (PUT → 追加, DEL → フラグ更新) -└──────┬──────────────┘ - │ - ▼ -┌─────────────────────┐ -│ Step 2: キュー追加 │ Write Queue に LogRecord を積む -└──────┬──────────────┘ - │ - ▼ -┌─────────────────────┐ -│ Step 3: バッチ書き込み │ flush_interval_ms (1000ms) または -│ │ max_pending_records (128件) に -│ │ 達したら write() で一括追記 -└──────┬──────────────┘ - │ - ▼ -┌─────────────────────┐ -│ Step 4: 定期 fsync │ fsync_interval_ms (5000ms) ごとに -│ │ fsync() でディスクに確実に落とす -└─────────────────────┘ - -設計パラメータ: - -#define FLUSH_INTERVAL_MS 1000 // キュー→ファイルの書き出し間隔 -#define FSYNC_INTERVAL_MS 5000 // fsync() の実行間隔 -#define MAX_PENDING_RECORDS 128 // キューの最大保留件数 - -4. 読み込みパイプライン(read_messages / search_messages) - -┌─────────────┐ -│ MCP Tool 呼出 │ read_messages / search_messages -└──────┬──────┘ - │ - ▼ -┌─────────────────────┐ -│ Step 1: インデックス │ In-Memory Index から該当レコードの -│ 検索 │ (file_id, offset, length) を取得 -└──────┬──────────────┘ - │ - ▼ -┌─────────────────────┐ -│ Step 2: pread() │ ログファイルの該当オフセットのみ -│ 部分読み出し │ pread(fd, buf, length, offset) -│ │ ※ログ全体は読み込まない -└──────┬──────────────┘ - │ - ▼ -┌─────────────────────┐ -│ Step 3: デシリアライズ│ LogRecord → Message Object に復元 -│ │ deleted フラグを確認し、 -│ │ 削除済みなら除外 -└──────┬──────────────┘ - │ - ▼ -┌─────────────────────┐ -│ Step 4: レスポンス │ JSON配列として MCP 経由で返却 -│ 返却 │ -└─────────────────────┘ - -5. 起動時復旧シーケンス - -プロセス起動 - │ - ▼ -┌──────────────────┐ -│ Step 1: │ -│ snapshot.bin 読込 │ 最後のスナップショットから -│ │ In-Memory Index を復元 -└──────┬───────────┘ - │ - ▼ -┌──────────────────┐ -│ Step 2: │ snapshot 作成時点以降の -│ ログ再生 │ active.log を先頭から順に読み、 -│ │ PUT / DEL レコードを適用 -│ │ (index を更新していく) -└──────┬───────────┘ - │ - ▼ -┌──────────────────┐ -│ Step 3: │ インデックスが完全に再構築された状態で -│ サービス開始 │ stdio 受付を開始 -└──────────────────┘ - -6. Snapshot 処理(rebuild_index の一部としても動作) - -現在有効なデータ (deleted=false) を列挙 - │ - ▼ -snapshot.tmp にシリアライズして書き出し - │ - ▼ -fsync(snapshot.tmp) - │ - ▼ -rename(snapshot.tmp → snapshot.bin) - │ ← アトミック操作。クラッシュしても古い snapshot.bin が残る - ▼ -完了 - -役割: 起動時のログ再生量を減らし、起動時間を短縮する。 -7. Compaction 処理 - -トリガー: ログファイルサイズが閾値を超えた または rebuild_index 呼出 - │ - ▼ -現在有効なデータ (deleted=false) のみを列挙 - │ - ▼ -new_active.log に有効レコードのみを書き出し - │ - ▼ -fsync(new_active.log) - │ - ▼ -rename(new_active.log → active.log) - │ ← アトミック操作 - ▼ -古いログファイルを削除 - │ - ▼ -新しい snapshot.bin も生成 (compaction 後の状態を保存) - │ - ▼ -In-Memory Index の file_id / offset を更新 - -役割: ログ肥大化を防ぎ、ディスク使用量を削減する。 -8. メモリ階層とデータライフサイクル - -Message Object の metadata.memory_tier を用いて、LLM会話の寿命を管理します。 - -┌─────────────┬──────────────────────────┬──────────────────┐ -│ 階層 │ 内容 │ 保存場所 │ -├─────────────┼──────────────────────────┼──────────────────┤ -│ short (短期) │ 直近の会話メッセージ │ メモリ + active.log│ -│ │ │ (即座に参照可能) │ -├─────────────┼──────────────────────────┼──────────────────┤ -│ mid (中期) │ セッションの要約・抽象化 │ active.log + │ -│ │ │ snapshot │ -├─────────────┼──────────────────────────┼──────────────────┤ -│ long (長期) │ 重要なユーザー設定・ │ snapshot 優先で │ -│ │ 継続的記憶 (key-value) │ 永続化 │ -├─────────────┼──────────────────────────┼──────────────────┤ -│ discard │ 一時的な雑談・重複情報・ │ compaction 時に │ -│ (破棄対象) │ 古い全文ログ │ 削除対象 │ -└─────────────┴──────────────────────────┴──────────────────┘ - -ライフサイクル: - -write_message (memory_tier=short) - │ - │ セッション終了・要約トリガー - ▼ -要約生成 → write_message (memory_tier=mid) - │ 元の short メッセージを delete_message で論理削除 - ▼ -compaction 時に short の deleted レコードが物理削除される - │ - │ 長期的に重要と判断されたもの - ▼ -write_message (memory_tier=long) - │ snapshot 優先で永続化 - ▼ -discard レコードは compaction 時に消える - -9. MCP Tool とストレージ層の対応表(最終版) -Tool L2処理 L3処理 関連コンポーネント -write_message Index更新 + Queue追加 flush時に active.log へ追記 Write Queue, In-Memory Index -read_messages session_id で Index 検索 pread() で該当レコード読み出し In-Memory Index, active.log -search_messages キーワードハッシュで Index 検索 pread() で該当レコード読み出し In-Memory Index, active.log -delete_message Index の type を DEL に更新 DEL レコードを active.log へ追記 Write Queue, In-Memory Index -rebuild_index 全有効データを再列挙 snapshot 作成 + compaction 実行 Snapshot, Compaction Worker -get_system_health 各種カウンタ収集 ログファイルサイズ・整合性チェック 全コンポーネント diff --git a/docs/development-plan.md b/docs/development-plan.md deleted file mode 100644 index c8199ee..0000000 --- a/docs/development-plan.md +++ /dev/null @@ -1,26 +0,0 @@ -# Tiny ConvMem development plan - -この表を改善計画の唯一の実装順序とする。状態は `未着手 / 設計中 / 実装中 / 検証中 / 完了 / blocked` のいずれかを使用する。 - -| ID | Phase | 依存ID | 設計項目 | 実装対象 | I/O予算 | テスト | Benchmark | 状態 | commit | 再開メモ | -|---|---|---|---|---|---|---|---|---|---|---| -| P0-001 | P0 | - | 作業台帳 | 本文書 | 0 write | 文書確認 | - | 完了 | working tree | P0-002から再開 | -| P0-002 | P0 | P0-001 | 論理I/O計測 | sys/store health | 計測自体は追加I/Oなし | counter unit test | 既存corpus baseline | 完了 | working tree | write/flush予算をstorage/E2Eで確認 | -| P0-003 | P0 | P0-002 | I/O budget gate | storage tests | 操作別予算に一致 | budget test | baseline JSON | 完了 | working tree | 固定上限assertとRelease baseline済み | -| P1-001 | P1 | P0-003 | JSONL export | read_messages view=export | 0 write/flush | pagination/round-trip | 1万/5万件 | 完了 | working tree | include_deleted対応済み | -| P1-002 | P1 | P1-001 | offline import | CLI / store | 一時ファイル各1 flush | conflict/rollback/round-trip | 1万/5万件 | 完了 | working tree | import_messages tool→CLI移行済み、benchmark・P1-CP未実施 | -| P1-CP | P1 | P1-002 | checkpoint | docs/status | - | Debug/Release/ASan | regression <=10% | 完了 | working tree | 10,000/50,000件 import/export round-trip benchmark 実施済み | -| P2-001 | P2 | P1-CP | v2 UPDATE delta | domain/storage | delta+header、1 flush | mixed replay/crash | 1MiB metadata更新 | 完了 | working tree | v1互換・delta実装済み | -| P2-002 | P2 | P2-001 | update_message | MCP tool | no-op 0/0 | revision conflict | latency/I/O | 完了 | working tree | E2E、再起動、metadata delta、no-op I/O確認済み | -| P2-CP | P2 | P2-002 | checkpoint | docs/status | - | Debug/Release/ASan | regression <=10% | 完了 | working tree | restart後の dangling relation 修復・mcp_protocol_test 再開済み | -| P3-001 | P3 | P2-CP | kind/importance/summary | domain/codec | metadata deltaのみ | validation/replay | payload matrix | 完了 | working tree | 6 kind、importance、summary永続化済み | -| P3-002 | P3 | P3-001 | typed relations | domain/index | read 0/0 | incoming/outgoing/cycle | branch 0/4/16 | 完了 | working tree | relation索引/BFS/depth0..2/input検証済み | -| P3-CP | P3 | P3-002 | checkpoint | docs/status | - | Debug/Release/ASan | regression <=10% | 完了 | working tree | P4-001完了済み | -| P4-001 | P4 | P3-CP | score explanation/filter | search_messages | 0 write/flush | score/order/filter | 1万/5万件 | 完了 | working tree | kind/tier/tag/importance、score内訳、context preview済み | -| P4-CP | P4 | P4-001 | checkpoint | docs/status | - | Debug/Release/ASan | regression <=10% | 完了 | working tree | P5-001完了済み | -| P5-001 | P5 | P4-CP | context view | read_messages | 0 write/flush | BFS/cycle/budget | depth 0..2 | 完了 | working tree | 前後文脈、incoming/outgoing、削除済み参照、8MiB予算済み | -| P5-CP | P5 | P5-001 | checkpoint | docs/status | - | Debug/Release/ASan | regression <=10% | 完了 | working tree | P6-001完了済み | -| P6-001 | P6 | P5-CP | sessions view | read_messages | 0 write/flush | aggregation/cursor | 1万/5万件 | 完了 | working tree | kind集計、代表summary、last_message_at+session_id cursor、索引列挙済み | -| P6-CP | P6 | P6-001 | checkpoint | docs/status | - | Debug/Release/ASan | regression <=10% | 完了 | working tree | P1-002/P7-001から再開 | -| P7-001 | P7 | P6-CP | host consolidation | write/search/read contract | summary 1 write/flush | workflow E2E | workflow latency | 完了 | working tree | implementation-status.md に host 側契約統合を文書化済み | -| P7-CP | P7 | P7-001 | final checkpoint | 全文書 | 全budget合格 | all tests | final baseline | 完了 | working tree | Debug/Release/ASan 全 16/16 成功、P1-CP benchmark 実施済み | diff --git a/docs/implementation-status.md b/docs/implementation-status.md deleted file mode 100644 index 20c35ae..0000000 --- a/docs/implementation-status.md +++ /dev/null @@ -1,308 +0,0 @@ -# 設計・実装・テスト状況一覧 - -最終更新日: 2026-07-04 - -この文書は、[architecture.md](architecture.md) の初期案、確定したプロトタイプ実装計画、 -現在のソースコード、CTestを突き合わせた追跡表です。設計案に存在しても確定したv1の -対象外となった項目は、単純な未実装と区別しています。 - -本コミット範囲は「restart resume 修復と P1 checkpoint 完了」の実装checkpointです。 - -- `delete_message` + `rebuild_index` 後に relation target が物理削除されると、restart 時に `memory_index` 構築で失敗し exit code 1 になる問題を修正しました。 -- 修正内容: - 1. `src/mcp/tools/common/memory_index.c`: relation target が存在しない場合にインデックス全体を破棄するのではなく、その relation をスキップするよう変更。 - 2. `src/storage/store.c`: `tiny_store_rebuild` と `write_snapshot` 実行前に、dangling relation / source をクリーンアップする `prune_metadata_references` を追加。 -- 診断用 `fprintf(stderr, ...)` を `src/mcp/server.c` と `src/mcp/tools/common/context.c` から削除。 -- `tests/mcp_protocol_test.py` の restart resume テストは既に有効で、削除→rebuild→restart→search の流れを検証しています。 -- P1-CP として `scripts/benchmark_p1_cp.py` を追加し、10,000/50,000件の offline JSONL import/export round-trip benchmark を実施しました。 - -## ステータスの読み方 - -| 表記 | 実装状況 | テスト作成状況 | テスト実施状況 | -|---|---|---|---| -| ✅ | 実装済 | 作成済 | 実施済・成功 | -| 🟡 | 部分実装、または設計との差異あり | 一部のみ作成済 | 一部条件のみ実施済 | -| ⬜ | 未実装 | 未作成 | 未実施 | -| ➖ | v1対象外、または採用しない設計 | 該当なし | 該当なし | - -「テスト作成済」はテストコードが存在すること、「実施済」は記載した構成で実際に成功した -ことを意味します。テスト済みでも、電源断などテスト環境で再現していない保証までは含みません。 - -## 現在の検証結果 - -| 構成 | ビルド | CTest | 実施日 | 結果 | -|---|---|---|---|---| -| Windows x64 / MSVC / Debug | ✅ 実施済 | ✅ 実施済 | 2026-07-04 | 16/16成功 | -| Windows x64 / MSVC / Release | ✅ 実施済 | ✅ 実施済 | 2026-07-04 | 16/16成功 | -| Windows x64 / MSVC / ASan | ✅ 実施済 | ✅ 実施済 | 2026-07-04 | 16/16成功 | -| Windows以外 | ⬜ 未実施 | ⬜ 未実施 | ― | v1対象外 | - -Python MCP SDK 1.28.1によるglobal/scoped検索1000回交互呼出しも、Debug・Release・ASanの -各構成で成功し、接続切断・異常終了・ASan診断がないことを確認しました。 -全read系操作(history/context/sessions/export/search)は0 write/0 flushを維持しています。 - -P1のgram bucket hash table化後、Releaseの32KiB write p50は128.242msから5.907msへ -約21.7倍高速化しました。1MiB write p50は90.093ms、domain単体のindex構築平均は -1KiB 0.217ms、8KiB 1.299ms、32KiB 3.948ms、1MiB 34.988msで、payload増加に対する -二次的悪化がないことを確認しました。 - -P2の大規模履歴試験では、1 session 1万件を10ページ・34.2msで全件取得し、restart -152.0ms、rebuild 109.0ms、peak working set 18.1MiBでした。100 session 5万件では -100ページ・159.0ms、restart 3.22s、rebuild 868.4ms、peak working set 48.9MiBで、 -重複・欠落はなく最大応答も210,173 bytes以下でした。 - -P1-CP benchmark 結果(Release、Windows x64 / MSVC): - -| messages | fixture bytes | import | export | round-trip import | export bytes | -|---|---|---|---|---|---| -| 10,000 | 3,167,946 | 305 ms (32,739 msg/s) | 173 ms (57,670 msg/s) | 317 ms (31,571 msg/s) | 3,647,942 | -| 50,000 | 15,883,946 | 4,797 ms (10,423 msg/s) | 4,264 ms (11,727 msg/s) | 4,898 ms (10,209 msg/s) | 18,283,942 | - -実行コマンド: - -```powershell -cmake --build --preset windows-vs2026-debug -ctest --preset windows-vs2026-debug -cmake --build --preset windows-vs2026-release -ctest --preset windows-vs2026-release -cmake --build --preset windows-vs2026-asan -ctest --preset windows-vs2026-asan -``` - -## アーキテクチャとプロセス - -| 設計項目 | 実装 | 実装根拠・現在の挙動 | テスト作成 | テスト実施 | 補足・残作業 | -|---|---|---|---|---|---| -| C17単一プロセスMCPサーバ | ✅ 実装済 | `src/app/main.c` と `src/mcp/server.c` | ✅ 作成済 | ✅ Debug/Release | 単一stdioクライアント前提 | -| MCP、ドメイン、ストレージの分離 | 🟡 部分実装 | ディレクトリと型は分離済み | ⬜ 未作成 | ⬜ 未実施 | MCP層がStore APIを直接呼ぶ。専用Memory Core/Application Serviceは未実装 | -| Domainのmcpc/Win32非依存 | ✅ 実装済 | `message.*`, `search.*` は標準Cのみ | ✅ 作成済 | ✅ Debug/Release | 依存方向の自動検査は未作成 | -| Repository function table | ⬜ 未実装 | MCP層へ `tiny_store_t` を直接渡す | ⬜ 未作成 | ⬜ 未実施 | ストレージ差替え契約は未抽象化 | -| `--data-dir <path>` 必須化 | ✅ 実装済 | 相対パスを絶対化し、不足時は終了コード2 | ✅ 作成済 | ✅ Debug/Release | `--help` の自動テストは未作成 | -| data directory自動作成 | ✅ 実装済 | UTF-8 `tiny_sys_directory_create` | 🟡 間接テスト | ✅ Debug/Release | 深い親ディレクトリの再帰作成は未実装 | -| data directory排他ロック | ✅ 実装済 | `store.lock` を共有なしで保持 | ✅ 作成済 | ✅ Debug/Release | 複数writerはv1対象外 | -| stdoutをJSON-RPC専用、stderrを診断専用 | ✅ 実装済 | プロトコル応答と起動エラーを分離 | 🟡 間接テスト | ✅ Debug/Release | stderr汚染の専用テストは未作成 | -| OS依存処理の分離 | ✅ 実装済 | `src/sys/system.c`だけでWin32/POSIXを切替え、公開APIはUTF-8・opaque handle | ✅ 作成済 | ✅ Windows Debug/Release | POSIX backendのCI実行は未作成 | -| Windows x64/MSVC対応 | ✅ 実装済 | sys層のWin32 backendとCMake preset | ✅ 作成済 | ✅ Debug/Release | Windows SDK差異のCIマトリクスは未作成 | - -## MCP互換境界と外部契約 - -| 設計項目 | 実装 | 実装根拠・現在の挙動 | テスト作成 | テスト実施 | 補足・残作業 | -|---|---|---|---|---|---| -| `initialize` / `tools/list` / `tools/call` | ✅ 実装済 | MCP 2024-11-05をvendored MCPC forkで処理 | ✅ 作成済 | ✅ Debug/Release/ASan | MCP全メソッド対応ではない | -| `notifications/initialized` | ✅ 実装済 | notificationとして応答せず継続 | ✅ 作成済 | ✅ Debug/Release/ASan | 次requestのIDで応答なしを検証 | -| raw JSON arguments | ✅ 実装済 | mcpc同梱mjsonでobjectを直接解析 | ✅ 作成済 | ✅ Debug/Release/ASan | malformed JSON、型不正、欠落を検証 | -| required/optional schema | ✅ 実装済 | schemaとruntimeで同じrequired属性を使用 | ✅ 作成済 | ✅ Debug/Release/ASan | 整数は`integer`として広告 | -| 64-bit timestamp/counter | ✅ 実装済 | JSON builderとバイナリ形式で64-bitを使用 | 🟡 間接テスト | ✅ Debug/Release | 最大/最小境界値は未作成 | -| callbackへのアプリ状態受渡し | ✅ 実装済 | server/handlerへStoreとSearch Indexを明示的に渡す | 🟡 間接テスト | ✅ Debug/Release | 汎用callback APIではない | -| MCPC依存管理 | ✅ vendored fork化 | submoduleを廃止し`third_party/mcpc`を親repositoryで追跡 | ✅ 単体・統合 | ✅ Debug/Release/ASan | upstream基準commitを`UPSTREAM.md`へ記録 | -| TextContent内JSON成功応答 | ✅ 実装済 | 全ツール結果をescape済みJSON文字列で返す | ✅ 作成済 | ✅ Debug/Release | `structuredContent` は未対応 | -| MCP application error (`isError=true`) | ✅ 実装済 | 検証/I/O/内部エラーで設定 | ✅ 検証エラー作成済 | ✅ Debug/Release | 全エラーコードのMCP経由試験は未作成 | -| 安定エラーコード | 🟡 部分実装 | `VALIDATION_ERROR`, `IO_ERROR`, `CORRUPT_STORE`, `BUSY`, `INTERNAL_ERROR` を定義・使用 | 🟡 一部作成済 | 🟡 一部実施済 | `BUSY`/`CORRUPT_STORE` は起動前に発生し、MCP応答にはならない | -| 不正JSON-RPCへの標準error object | ✅ 実装済 | `-32700/-32600/-32601/-32602/-32603`をJSON-RPC 2.0形式で返す | ✅ 作成済 | ✅ Debug/Release/ASan | Python SDK相互運用も確認 | - -## MCPツール別状況 - -| Tool / 要件 | 実装 | テスト作成 | テスト実施 | 現在の確認範囲・不足 | -|---|---|---|---|---| -| `write_message` 基本書込み | ✅ 実装済 | ✅ 作成済 | ✅ Debug/Release | MCPから書込み、応答Message、再起動後読込みを確認 | -| サーバ生成ULID | ✅ 実装済 | ✅ 作成済 | ✅ Debug/Release | Crockford形式と同一プロセス内の単調順を確認 | -| サーバ生成UTCミリ秒 | ✅ 実装済 | 🟡 間接テスト | ✅ Debug/Release | 実時刻との許容差・時計巻戻り専用テストは未作成 | -| role検証 | ✅ 実装済 | ✅ 作成済 | ✅ Debug/Release | 有効/無効値をDomainで確認 | -| metadata `tool_call_id` | ✅ 実装済 | ⬜ 未作成 | ⬜ 未実施 | round-tripと上限境界が未確認 | -| metadata `tags` | ✅ 実装済 | ✅ 作成済 | ✅ Debug/Release | 日本語tag検索をDomainで確認。32件/128 byte境界は未確認 | -| metadata `memory_tier` | ✅ 実装済 | ✅ 作成済 | ✅ Debug/Release | enumとMCP入力を確認。自動ライフサイクルは対象外 | -| `read_messages` history pagination | ✅ 実装済 | ✅ 作成済 | ✅ Debug/Release | 既定100、最大1000、8MiB応答境界、restart/rebuild cursor継続 | -| `read_messages` context view | ✅ 実装済 | ✅ 作成済 | ✅ Debug/Release | relation depth 0~2、type filter、BFS、visited set、前後文脈、incoming/outgoing、削除済み参照 | -| `read_messages` sessions view | ✅ 実装済 | ✅ 作成済 | ✅ Debug/Release | kind別件数、代表summary、last_message_at+session_id安定cursor、O(n²)集計を索引列挙へ置換 | -| `read_messages` export view | ✅ 実装済 | ✅ 作成済 | ✅ Debug/Release | JSONL、安定cursor、include_deleted、約8MiB応答予算 | -| timestamp、message_id安定順序 | ✅ 実装済 | ⬜ 未作成 | ⬜ 未実施 | 複数件・同一timestampの順序試験が必要 | -| `search_messages` content検索 | ✅ 実装済 | ✅ 作成済 | ✅ Debug/Release | ASCII大小文字とMCP検索を確認 | -| `search_messages` score内訳 | ✅ 実装済 | ✅ 作成済 | ✅ Debug/Release | totalとcontent/summary/tagsが同一計算から生成、importanceはtie-breakのみ | -| `search_messages` 複合filter | ✅ 実装済 | ✅ 作成済 | ✅ Debug/Release | filters_jsonで複数kind/tier/tagを受け、単数filterは置換済み | -| `search_messages` context preview | ✅ 実装済 | ✅ 作成済 | ✅ Debug/Release | context_size=0..3、8MiB応答予算 | -| 日本語・tag検索 | ✅ 実装済 | ✅ 作成済 | ✅ Debug/Release | Domainで確認 | -| `session_id` 絞込み | ✅ 実装済 | ✅ 作成済 | ✅ Debug/Release/ASan | 2 sessionでglobal/scopedを1000回交互に確認 | -| `limit` 既定20・最大100 | ✅ 実装済 | ✅ 作成済 | ✅ Debug/Release/ASan | 省略、0、101、少数拒否を統合試験で確認 | -| 検索順位とtie-break | ✅ 実装済 | 🟡 基本scoreのみ | 🟡 一部実施済 | 複数候補で exact match、timestamp、ID順を検証していない | -| `delete_message` 即時不可視化 | ✅ 実装済 | ✅ 作成済 | ✅ Debug/Release | lifecycle testでdelete後のreadから消えることを確認 | -| `delete_message` session照合 | ✅ 実装済 | 🟡 optional省略を確認 | 🟡 一部実施済 | session不一致時のNOT_FOUND未確認 | -| `delete_message` NOT_FOUND | ✅ 実装済 | ⬜ 未作成 | ⬜ 未実施 | 存在しないID、二重削除の統合試験が必要 | -| `update_message` 部分更新 | ✅ 実装済 | ✅ 作成済 | ✅ Debug/Release | contentとmetadataのpresence maskによるdelta、importance-only更新はcontentを再書込みしない | -| `update_message` 耐久性 | ✅ 実装済 | ✅ 作成済 | ✅ Debug/Release | prepare→append/flush→no-fail swap。空content/不正relationはdisk書込み前に拒否、flush失敗はメモリ/revisionを変更しない | -| `update_message` revision制約 | ✅ 実装済 | ✅ 作成済 | ✅ Debug/Release | 1..INT32_MAX、上限到達で拒否、optimistic concurrency | -| `update_message` relation/source検証 | ✅ 実装済 | ✅ 作成済 | ✅ Debug/Release | 不存在、自己参照、重複、source ID重複をwrite/update共通で検証 | -| `rebuild_index` 同期compaction | ✅ 実装済 | ✅ 作成済 | ✅ Debug/Release | MCP呼出しと削除本文の物理除去を確認 | -| rebuild結果カウンタ | ✅ 実装済 | ✅ 作成済 | ✅ Debug/Release | `records_removed=1` を確認 | -| `get_system_health` 基本カウンタ | ✅ 実装済 | ✅ 作成済 | ✅ Debug/Release | log/snapshot size、件数、sequenceを出力 | -| health `OK` | ✅ 実装済 | ✅ 作成済 | ✅ Debug/Release | smokeで確認 | -| health `DEGRADED` | ✅ 実装済 | ✅ 作成済 | ✅ Debug/Release | 不正snapshot/末尾復旧後に確認 | -| health `ERROR` | ⬜ 未実装 | ⬜ 未作成 | ⬜ 未実施 | 致命的Store破損ではサーバが起動しないためtoolを呼べない | -| 復旧履歴 | 🟡 部分実装 | 🟡 一部作成済 | ✅ Debug/Release | `recovered_tail` と直近 `last_error` のみ。履歴配列は未実装 | - -## Messageと入力制約 - -| 設計項目 | 実装 | テスト作成 | テスト実施 | 補足・残作業 | -|---|---|---|---|---| -| immutable Messageモデル | ✅ 実装済 | ✅ 作成済 | ✅ Debug/Release | 更新APIを持たず、deep copyで所有権を分離 | -| `message_id` 26文字ULID | ✅ 実装済 | ✅ 作成済 | ✅ Debug/Release | 不正alphabet/先頭値を確認 | -| `session_id` 最大256 bytes | ✅ 実装済 | ⬜ 未作成 | ⬜ 未実施 | 0、256、257 bytes境界が未確認 | -| `content` 最大1 MiB | ✅ 実装済 | ⬜ 未作成 | ⬜ 未実施 | 1 MiB前後、長大JSON lineの統合試験が未作成 | -| `tool_call_id` 最大256 bytes | ✅ 実装済 | ⬜ 未作成 | ⬜ 未実施 | 境界試験なし | -| tags最大32件、各128 bytes | ✅ 実装済 | ⬜ 未作成 | ⬜ 未実施 | 境界試験なし | -| query最大4096 bytes | ✅ 実装済 | ⬜ 未作成 | ⬜ 未実施 | 境界試験なし | -| UTF-8妥当性 | ✅ 実装済 | ✅ 作成済 | ✅ Debug/Release | 基本的なvalid/overlong invalidを確認。全不正系列は未網羅 | -| `metadata.deleted` の排除 | ✅ 実装済 | 🟡 schemaで間接確認 | ✅ Debug/Release | 削除状態はStore entry/DELのみ | -| checksumを論理metadataから排除 | ✅ 実装済 | ➖ 該当なし | ➖ 該当なし | CRC32を物理recordで保持 | - -## 検索索引 - -| 設計項目 | 実装 | テスト作成 | テスト実施 | 補足・残作業 | -|---|---|---|---|---| -| Unicode codepoint 2/3-gram逆引き索引 | ✅ 実装済 | ✅ 作成済 | ✅ Debug/Release | contentとtagsを索引化 | -| gram bucket hash table | ✅ 実装済 | ✅ 作成済 | ✅ Debug/Release/ASan | open addressing、最大load factor 70%、完全key照合 | -| ASCII case folding | ✅ 実装済 | ✅ 作成済 | ✅ Debug/Release | `CURRY`/`curry` を確認 | -| 日本語検索 | ✅ 実装済 | ✅ 作成済 | ✅ Debug/Release | `設計` tagを確認 | -| hash衝突時の実codepoint照合 | ✅ 実装済 | ✅ 作成済 | ✅ Debug/Release/ASan | test hashを8-bit化し、300個の異なるgramで衝突を強制 | -| index追加のallocation failure atomicity | ✅ 実装済 | ✅ 作成済 | ✅ Debug/Release/ASan | 全allocation位置を掃引し、部分登録・leakなしを確認 | -| 1 codepoint query fallback | ✅ 実装済 | ⬜ 未作成 | ⬜ 未実施 | 全候補からscore計算する経路が未確認 | -| 起動時索引構築 | ✅ 実装済 | 🟡 再起動で間接確認 | ✅ Debug/Release | 大量データ時の時間/メモリ試験なし | -| write時索引更新 | ✅ 実装済 | ✅ MCP検索で確認 | ✅ Debug/Release | index allocation失敗のfault injectionなし | -| compaction後索引再構築 | ✅ 実装済 | 🟡 rebuild後health/readのみ | 🟡 一部実施済 | rebuild直後のsearch専用試験が必要 | -| `message_id -> entry` hash map | ⬜ 未実装 | ⬜ 未作成 | ⬜ 未実施 | 現在は配列の線形探索 | -| `session_id -> message list` index | ✅ 実装済 | ✅ 作成済 | ✅ Debug/Release | open addressing session mapと安定順序vector | -| Unicode normalization/full case folding | ➖ v1対象外 | ➖ 該当なし | ➖ 該当なし | NFKC、非ASCII大文字小文字変換は未対応 | - -## バイナリログと耐久性 - -| 設計項目 | 実装 | テスト作成 | テスト実施 | 補足・残作業 | -|---|---|---|---|---| -| `active.log` append-only | ✅ 実装済 | ✅ 作成済 | ✅ Debug/Release | compaction時のみ置換 | -| little-endian versioned header | ✅ 実装済 | 🟡 round-tripのみ | ✅ Debug/Release | byte単位golden fixtureは未作成 | -| magic/version/type/length/sequence/CRC32 | ✅ 実装済 | 🟡 CRC破損あり | ✅ Debug/Release | 各header fieldの単独異常試験は未作成 | -| PUT payload round-trip | ✅ 実装済 | ✅ 作成済 | ✅ Debug/Release | write→close→reopenで確認 | -| DEL payload round-trip | ✅ 実装済 | ✅ 作成済 | ✅ Debug/Release | delete→再起動→compactionで確認 | -| generation marker | ✅ 実装済 | 🟡 lifecycleで間接確認 | ✅ Debug/Release | marker単独のbinary testは未作成 | -| 64-bit file offset API | ✅ 実装済 | ⬜ 未作成 | ⬜ 未実施 | 4 GiB超のsparse file試験は未作成 | -| record長上限とoverflow防止 | ✅ 実装済 | ⬜ 未作成 | ⬜ 未実施 | 巨大length/整数overflow fixtureが必要 | -| PUT/DEL後のdurability flush | ✅ 実装済 | 🟡 sys API成功経路のみ | ✅ Debug/Release | flush失敗fault injection、実電源断は未実施 | -| 成功前のメモリ確保 | ✅ 実装済 | ⬜ 未作成 | ⬜ 未実施 | allocation failure注入は未作成 | -| OS書込み後・flush前クラッシュ | 🟡 コード上復旧可能 | ⬜ 未作成 | ⬜ 未実施 | プロセス強制終了/電源断試験が必要 | -| record offset/lengthだけを保持するIndexEntry | ⬜ 未実装 | ⬜ 未作成 | ⬜ 未実施 | 現在はMessage全体をメモリ保持 | -| offset指定部分読出し | ⬜ 未実装 | ⬜ 未作成 | ⬜ 未実施 | 現在のread/searchはメモリ上Messageを使用 | - -## 起動時復旧とsnapshot - -| 設計項目 | 実装 | テスト作成 | テスト実施 | 補足・残作業 | -|---|---|---|---|---| -| 空Store起動 | ✅ 実装済 | ✅ 作成済 | ✅ Debug/Release | smokeで確認 | -| ログ全再生 | ✅ 実装済 | ✅ 作成済 | ✅ Debug/Release | storage/lifecycleで再起動確認 | -| snapshot生成 | ✅ 実装済 | ✅ 作成済 | ✅ Debug/Release | compaction時にlive Messageを保存 | -| generation/CRC/log offset検証 | ✅ 実装済 | ✅ 不正snapshot作成済 | ✅ Debug/Release | generation不一致そのものの専用fixtureは未作成 | -| 有効snapshotから状態復元 | ✅ 実装済 | 🟡 snapshot生成のみ | 🟡 一部実施済 | 有効snapshotを使った再起動を明示判定するテストは未作成 | -| snapshot offset以降のtail再生 | ✅ 実装済 | ⬜ 未作成 | ⬜ 未実施 | snapshot後にPUT/DELして再起動するテストが必要 | -| 不正snapshotを無視して全ログ再生 | ✅ 実装済 | ✅ 作成済 | ✅ Debug/Release | lifecycleで不正内容へ置換して確認 | -| EOF末尾の不完全record切詰め | ✅ 実装済 | ✅ 作成済 | ✅ Debug/Release | `TC` を追記して確認 | -| 完全recordのCRC不一致で起動失敗 | ✅ 実装済 | ✅ 作成済 | ✅ Debug/Release | 最終complete recordを破損して確認 | -| ログ途中recordの破損 | ✅ 実装済 | ⬜ 未作成 | ⬜ 未実施 | 複数recordの中間を破損する専用試験が必要 | -| 未知version/type拒否 | ✅ 実装済 | ⬜ 未作成 | ⬜ 未実施 | binary fixture未作成 | -| 非単調sequence拒否 | ✅ 実装済 | ⬜ 未作成 | ⬜ 未実施 | binary fixture未作成 | -| snapshot作成中クラッシュ | 🟡 原子的置換で設計 | ⬜ 未作成 | ⬜ 未実施 | `snapshot.tmp` 各段階の強制終了試験が必要 | - -## Compactionと削除保証 - -| 設計項目 | 実装 | テスト作成 | テスト実施 | 補足・残作業 | -|---|---|---|---|---| -| DEL後の即時不可視化 | ✅ 実装済 | ✅ 作成済 | ✅ Debug/Release | readから除外されることを確認 | -| live PUTだけの新ログ生成 | ✅ 実装済 | ✅ 作成済 | ✅ Debug/Release | 削除本文のbyte列が消えることを確認 | -| 一時ログのflush | ✅ 実装済 | 🟡 成功経路のみ | ✅ Debug/Release | Flush失敗注入は未作成 | -| `MoveFileExW` によるログ置換 | ✅ 実装済 | 🟡 成功経路のみ | ✅ Debug/Release | 置換失敗/直前クラッシュ試験は未作成 | -| ログ置換後のsnapshot置換 | ✅ 実装済 | 🟡 成功経路のみ | ✅ Debug/Release | 2ファイル間クラッシュ窓の強制終了試験は未作成 | -| compaction失敗時の旧Store維持 | 🟡 部分確認 | ✅ temp作成失敗を作成 | ✅ Debug/Release | replacement段階の失敗は未確認 | -| `rebuild_index` 手動トリガー | ✅ 実装済 | ✅ 作成済 | ✅ Debug/Release | 同期実行中はstdio処理をブロック | -| ログサイズ閾値による自動compaction | ⬜ 未実装 | ⬜ 未作成 | ⬜ 未実施 | v1実装では手動rebuildのみ | -| background compaction worker | ➖ 採用しない | ➖ 該当なし | ➖ 該当なし | 単一スレッド同期方式へ確定変更 | -| SSD/バックアップからの安全消去 | ➖ v1対象外 | ➖ 該当なし | ➖ 該当なし | APIの物理削除保証には含めない | - -## メモリ階層とライフサイクル - -| 設計項目 | 実装 | テスト作成 | テスト実施 | 補足・残作業 | -|---|---|---|---|---| -| `short/mid/long/discard` 属性保存 | ✅ 実装済 | ✅ 作成済 | ✅ Debug/Release | tierは検索/削除ポリシーに影響しない | -| session終了トリガー | ➖ v1対象外 | ➖ 該当なし | ➖ 該当なし | 未実装 | -| LLM要約生成 | ➖ v1対象外 | ➖ 該当なし | ➖ 該当なし | 外部LLM連携なし | -| short→mid→long自動昇格 | ➖ v1対象外 | ➖ 該当なし | ➖ 該当なし | 未実装 | -| discard自動破棄 | ➖ v1対象外 | ➖ 該当なし | ➖ 該当なし | discardも明示deleteまで保持 | -| retention/TTL | ➖ v1対象外 | ➖ 該当なし | ➖ 該当なし | 未実装 | - -## テスト資産一覧 - -| CTest名 | 種別 | テスト作成 | Debug | Release | 主な対象 | -|---|---|---|---|---|---| -| `tiny-convmem-mcp.smoke` | MCP E2E | ✅ 作成済 | ✅ 成功 | ✅ 成功 | initialize、6 tool schema、write/read/search/rebuild/health、検証エラー | -| `tiny-convmem-mcp.domain` | Unit | ✅ 作成済 | ✅ 成功 | ✅ 成功 | role/tier、UTF-8、ULID、deep copy、ASCII/日本語n-gram | -| `tiny-convmem-mcp.search-index` | Unit | ✅ 作成済 | ✅ 成功 | ✅ 成功 | hash衝突、rehash、Unicode、allocation failure atomicity | -| `tiny-convmem-mcp.mcpc` | Unit | ✅ 作成済 | ✅ 成功 | ✅ 成功 | required属性、NULL callback拒否、fork API | -| `tiny-convmem-mcp.protocol` | MCP E2E | ✅ 作成済 | ✅ 成功 | ✅ 成功 | JSON-RPC異常系、schema/default、1000回交互検索、終了診断 | -| `tiny-convmem-mcp.storage` | Unit/Integration | ✅ 作成済 | ✅ 成功 | ✅ 成功 | lock、write/reopen、ULID順、temp失敗、CRC破損拒否 | -| `tiny-convmem-mcp.lifecycle` | MCP/Storage E2E | ✅ 作成済 | ✅ 成功 | ✅ 成功 | delete、不可視化、compaction、本文除去、不正snapshot、末尾復旧 | -| `tiny-convmem-mcp.requires-data-dir` | Process | ✅ 作成済 | ✅ 成功 | ✅ 成功 | data-dir必須、非0終了 | -| `tiny-convmem-mcp.boundaries` | Unit | ✅ 作成済 | ✅ 成功 | ✅ 成功 | encoding、unaligned codec、allocator、JSON、sys fault hook | -| `tiny-convmem-mcp.cli` | Process | ✅ 作成済 | ✅ 成功 | ✅ 成功 | `--import/--export` CLI、round-trip、manifest、policy | -| `tiny-convmem-mcp.audit` | Static | ✅ 作成済 | ✅ 成功 | ✅ 成功 | clang-format、diff check、goto、OS依存隔離 | - -## 未実装・テスト不足の優先バックログ - -| 優先度 | 項目 | 現在の状態 | 完了条件 | -|---|---|---|---| -| P1 | 有効snapshot+tail replay試験 | ⬜ 未作成・未実施 | snapshot後のPUT/DELだけがtailから適用されることを確認 | -| P1 | binary corruption matrix | 🟡 一部のみ | unknown version/type、巨大length、非単調sequence、中間CRC破損を自動試験 | -| P1 | 入力サイズ境界 | ⬜ 未作成・未実施 | 全上限の境界内成功/境界外拒否をMCP経由で確認 | -| P1 | offline JSONL import | ✅ 実装済 | `--import <file>` CLI で JSONL を読み込み、overwrite/reject ポリシーで検証 | -| P1 | deleted export | ✅ 実装済 | `read_messages view=export include_deleted=true` でDEL tombstoneを出力 | -| P1 | 10,000/50,000件 P1 往復 benchmark | ✅ 実施済 | `scripts/benchmark_p1_cp.py` で import/export round-trip を計測、1万件/5万件成功 | -| P2 | read/searchの安定順序 | 🟡 実装済・試験不足 | 複数session/同時刻/同score/limitを網羅 | -| P2 | compaction crash/failure matrix | 🟡 一部のみ | flush、rename前後、log/snapshot間の各停止点から復旧 | -| P2 | 4 GiB超offset | ⬜ 未作成・未実施 | sparse logで64-bit seek/replayを確認 | -| P3 | ログサイズ閾値compaction | ⬜ 未実装 | 設定閾値超過時に安全に同期compactionする | -| P3 | 性能・メモリ試験 | 🟡 長時間接続のみ実装 | 大量message、巨大session、繰返しrebuildの基準値を定義・測定 | - -## Host-side tool contract consolidation (P7-001) - -MCP host(LLM プロセス側)は、現在の 7 tool を用途に応じて組み合わせて呼び出すことで、 -会話の書込み・検索・読出しワークフローを完成させます。v1 ではサーバー側に新しい -ワークフロー tool は追加せず、以下の既存 tool の契約を host 側で統合して利用します。 - -| ワークフロー | 利用 tool | 契約 | -|---|---|---| -| 通常の会話書込み | `write_message` | 1 write + 1 flush で永続化 | -| 会話要約の保存 | `write_message` + `update_message` | 元メッセージを `update_message` で要約・relation/source 付与 | -| セッション履歴取得 | `read_messages` | `view=history`、keyset pagination、最大 1,000 件/8 MiB | -| 関連文脈取得 | `read_messages` | `view=context`、relation depth 0..2、前後文脈 | -| セッション一覧取得 | `read_messages` | `view=sessions`、session 別集計 | -| 情報検索 | `search_messages` | 0 write/0 flush、ranked 20..100 件、filter と score 内訳 | -| 古いメッセージ削除 | `delete_message` + `rebuild_index` | `delete_message` で即時不可視化、`rebuild_index` で物理除去 | -| 健全性確認 | `get_system_health` | 起動直後に呼び出し、`OK`/`DEGRADED` を判定 | -| バックアップ・移行 | `--import` / `--export` CLI | offline JSONL round-trip、overwrite/reject ポリシー | - -Host 側の責務: - -1. タイムスタンプはサーバー生成 ULID の一部として扱われるため、host は UTC ミリ秒を渡さない。 -2. `write_message` 成功後は durability が保証される。 -3. `search_messages` と `read_messages` は追加 write/flush を発生しない。 -4. 要約生成・ tier 昇格・ discard 自動化は v1 では host 側の責務または対象外。 -5. `delete_message` 後に `rebuild_index` を呼ぶまで、tombstone は物理的に残る。 - -ワークフロー E2E の検証用には、`tests/mcp_protocol_test.py` が write → read context/sessions → search 1000 回 → delete → rebuild → restart → search の一連を実行しており、host consolidation の基盤となる契約を網羅しています。 - -## 明示的なv1対象外 - -| 項目 | 状態 | 理由 | -|---|---|---| -| 非同期write queue、定期flush/fsync | ➖ 非採用 | `OK` 後の消失を避ける同期耐久性を優先 | -| background worker | ➖ 非採用 | 単一stdio・単一スレッドの初期実装に限定 | -| 自動要約、tier昇格、discard削除 | ➖ 対象外 | LLMライフサイクルは6 tool完成後の別機能 | -| encryption at rest | ➖ 対象外 | 鍵管理を含む別設計が必要 | -| search pagination | ➖ 対象外 | `read_messages`のみkeyset pagination対応 | -| 複数writer/network server | ➖ 対象外 | data directoryを単一プロセスが排他所有 | -| SSD/バックアップの安全消去 | ➖ 対象外 | filesystem compactionだけでは保証不能 | -| Linux/macOS | 🟡 backend実装・未保証 | POSIX file/lock/clock/random実装あり。CI・実機検証は未実施 | diff --git a/docs/review-and-next-plan.md b/docs/review-and-next-plan.md deleted file mode 100644 index 94523cd..0000000 --- a/docs/review-and-next-plan.md +++ /dev/null @@ -1,149 +0,0 @@ -# Critical Review of Committed Implementation + Next-Task Plan - -## 1. 現在の実装状況(コミット済: `0634f20` まで) - -| Phase | ID | 状態 | 設計通りか | -|---|---|---|---| -| P0 | 001-003 | 完了 | ✅ 計測、budget、gate が揃っており、`docs/storage-io-budget.md` と整合 | -| P1 | 001 | 完了 | ✅ JSONL export (`view=export`) は read-only、8MiB 予算内 | -| P1 | 002 | 完了 | ⚠️ **実装経路が変更された**。当初は MCP `import_messages` tool → 最終的に独立 CLI へ移行。I/O 予算は一時ファイル 1 flush を守っているが、tool 数は 7 に戻した | -| P2 | 001-002 | 完了 | ✅ delta レコード、optimistic revision、no-op 0/0 を確認 | -| P3 | 001-002 | 完了 | ✅ 6 kind/importance/summary/relations 永続化と BFS context | -| P4 | 001 | 完了 | ✅ score breakdown、filters、context preview | -| P5 | 001 | 完了 | ✅ 前後文脈、incoming/outgoing、8MiB 予算 | -| P6 | 001 | 完了 | ✅ sessions view は O(n²) 集計を索引列挙へ置換済み | -| P7 | 001 | 未着手 | — | - -## 2. 批判的レビュー:コミット済実装の設計乖離とリスク - -### 2.1 P1-002 offline import — 実装経路の変更 - -- **当初計画**: `import_messages` MCP tool 経由で JSONL を取り込む。 -- **実際**: MCP tool を削除し、独立した CLI (`--import <file>` / `--export <file>`) に移行。 -- **メリット**: tool 数を 7 に維持でき、ホスト連携前のオフライン運用に適する。MCP レイヤーがファイル I/O を持たない。 -- **リスク**: - - ホストが import をトリガーしたい場合、プロセス起動が必要(専用 tool なし)。 - - `import` 実行中に同一 data-dir への MCP サーバー接続はロックで失敗する。これは設計通りだが、運用ドキュメントに明記が必要。 - - `tiny_store_import()` で所有権を deep copy に修正したが、同様の shallow-copy バグが他にないか grep で再確認が必要。 - -### 2.2 プロトコルテストの restart resume — 未解決 - -- `tests/mcp_protocol_test.py` 540-545 行目がコメントアウトされている。 -- コメントには「restart exit code 1 pending root-cause」とあるが、復元すべきコードは `restarted.send(...)` の stub のみ。 -- **実験**: 最小 restart probe (`tests/restart_probe.py`) では、write→close→restart→initialize が成功。 -- **未検証**: - - `rebuild_index` 後の snapshot 読み込み経路で restart する場合。 - - delete 後、compaction 後、大規模 search 後の restart。 - - `server.close()` 直後の lock 解放タイミング(Windows handle 継承の問題)。 -- **推定リスク**: 実際には特定の操作後(特に `rebuild_index`)にのみ restart が失敗する可能性がある。stub では再現しない。 - -### 2.3 性能・スケール — 大規模 P1 往復 benchmark 未実施 - -- `search_index_benchmark.c` があるが、P1-CP に必要な「context view / sessions view の O(n²) 退行確認」「応答 8 MiB 以下」「latency regression 10% 以内」は未測定。 -- 1万/5万件の import/export round-trip も未実施。 -- 現状の sessions view は索引列挙に置換したが、大規模データでのメモリ・レイテンシを計測していない。 - -### 2.4 P7 host consolidation — 未着手 - -- write/read/search 契約の文書化が未着手。 -- summary memory kind の利用ガイドも未作成。 -- これはリリース直前のドキュメント整備タスク。 - ---- - -## 3. 次タスクのプラン - -ユーザー提示の優先順位案を受け、以下のように立案する。 - -### P1: P1-CP 達成のための 10,000/50,000 件 P1 往復 benchmark - -**目的**: P1 checkpoint を完了させ、import/export + context/sessions view のスケールを検証する。 - -**実装**: - -1. `tests/import_export_benchmark.c` を新設(または `tests/search_index_benchmark.c` を拡張)。 -2. 以下のシナリオを測定: - - 1 万件 import → export round-trip(時間、I/O カウンタ、ファイルサイズ)。 - - 5 万件 import → export round-trip。 - - import 後の context view (`view=context`, depth 1/2) latency と応答サイズ。 - - import 後の sessions view latency と応答サイズ。 -3. assert 条件: - - 全 read view で `write_bytes == 0 && flush_calls == 0`。 - - 応答サイズ <= 8 MiB。 - - 各 view の latency が既存 baseline(1 万件 history 100 件 p50 34.2ms 程度)に対して <= 10% regression。 - -**テスト方法**: - -```powershell -cmake --build --preset windows-vs2026-release -.\build\windows-vs2026\tests\Release\tiny-convmem-import-benchmark.exe -``` - -**成果物**: -- 新規 benchmark ソース + CTest 統合。 -- `docs/implementation-status.md` の P1 benchmark 結果更新。 -- P1-CP を「完了」にするための PR。 - -### P2: protocol test restart resume 退行調査 - -**目的**: `tests/mcp_protocol_test.py` のコメントアウトを復活させ、再起動後の initialize 失敗原因を特定・修正する。 - -**手順**: - -1. `Server.close()` の実装を見直し、`stdin` close 後の `stderr` drain と `wait()` の順序を確認。 -2. `rebuild_index` 後の restart を含む最小再現スクリプトを作成(既存 `tests/restart_probe.py` を拡張)。 -3. 失敗が再現したら、stderr / exit code / `active.log` / `snapshot.bin` を収集して原因を特定: - - snapshot generation mismatch - - lock 未解放 - - `replay_log` での CRC / sequence 拒否 -4. 修正後、protocol test のコメントアウト部分を実装し、以下を検証: - - restart 後に `tools/list` が成功。 - - restart 後に直前の write/search/read が継続して動作。 - - 1000 回 search loop の前後で restart しても成功。 - -**成果物**: -- `tests/mcp_protocol_test.py` の restart resume 部分を有効化。 -- 修正が必要な場合は該当 C コード + テスト更新。 -- `docs/implementation-status.md` に restart coverage を追記。 - -### P3: P7 host consolidation(最終段階) - -**目的**: リリース可能な状態にするための契約文書化。 - -**内容**: - -1. `docs/host-consolidation.md` を新設し、以下を定義: - - `write_message` 契約: 必須/オプション、ULID 生成、エラー時の耐久性保証範囲。 - - `read_messages` 契約: history/context/sessions/export 各 view の入力スキーマ、cursor 形式、応答予算。 - - `search_messages` 契約: query、filter、limit、score breakdown、context_size。 - - `update_message` 契約: revision 楽観ロック、部分更新。 - - `delete_message` 契約: session guard、即時不可視化。 - - `rebuild_index` / `get_system_health` 契約: 同期実行、診断情報。 -2. `summary` memory kind の利用ガイド: - - summary kind の意味、relation/source との関係、ホストが summary を生成・更新する流れ。 - - importance、memory_tier、memory_kind の組み合わせ例。 -3. `README.md` の更新: - - 7 tool 概要、CLI import/export、MCP 接続方法、I/O budget 方針。 - -**成果物**: -- `docs/host-consolidation.md`。 -- `README.md` 更新。 -- P7-001 / P7-CP を完了にできる状態。 - ---- - -## 4. 推奨する実行順序 - -| 順序 | タスク | 理由 | -|---|---|---| -| 1 | **P2: restart resume 調査** | 現在のテストにコメントアウトがあり、信頼性に関わる。直近の P1 benchmark や P7 文書化より先に解消すべき。 | -| 2 | **P1: P1-CP benchmark** | P1 checkpoint を完了させ、以降の P2/P3/P4 checkpoint との整合を取る。 | -| 3 | **P3: P7 host consolidation** | すべての機能が揃った後の文書化作業。リリース直前。 | - ---- - -## 5. 即座に確認すべき懸念事項 - -1. `tiny_store_import()` の deep-copy 修正後、他に shallow-copy して caller と共有している箇所がないか grep 確認。 -2. `server.close()` 直後に同じ data-dir へ再接続できることを、`rebuild_index` 後も含めて CTest に追加。 -3. CLI import/export のロック衝突時のエラーメッセージと終了コードを整理(現在 `TINY_STORE_BUSY` は終了コード 3)。
\ No newline at end of file diff --git a/docs/storage-io-budget.md b/docs/storage-io-budget.md deleted file mode 100644 index bdee602..0000000 --- a/docs/storage-io-budget.md +++ /dev/null @@ -1,20 +0,0 @@ -# Storage I/O budget - -成功応答前のdurability flush保証を維持し、論理I/O counterを正式な合否判定に使用する。物理SSD測定値はOS cacheと機種差があるため同一環境内の参考値とする。 - -| 操作 | 最大logical write | 最大flush | -|---|---:|---:| -| write_message | record header + serialized message | 1 | -| metadata-only update | record header + changed fields | 1 | -| content update | record header + new content + changed fields | 1 | -| no-op update | 0 | 0 | -| delete_message | fixed tombstone record | 1 | -| read/search/context/export/sessions | 0 | 0 | -| no-op rebuild | 0 | 0 | -| rebuild | live serialized bytes x 2 + bounded headers | log 1 + snapshot 1 | - -## Measurement - -sys境界でwrite bytes、write calls、flush calls、truncate calls、replace callsを数える。Release buildで20B、1KiB、8KiB、32KiB、1MiB payloadおよび1万・5万件corpusを測定し、理由のない10%以上の性能回帰を失敗とする。 - -metadata-only UPDATEはfield bitmaskと差分だけを追記し、content長に比例してはならない。読取り系操作は索引とメモリ上のMessageだけを利用し、アクセス記録を永続化しない。 diff --git a/src/cli/export.c b/src/cli/export.c index 4288ba4..d7397c4 100644 --- a/src/cli/export.c +++ b/src/cli/export.c @@ -3,17 +3,42 @@ #include "mcp/tools/common/common.h" #include "storage/codec.h" #include "storage/store.h" +#include "sys/system.h" #include <stdio.h> #include <stdlib.h> #include <string.h> +static bool +export_finish(FILE *output, const char *temporary_path, const char *destination) +{ + bool ok = fflush(output) == 0; + if (output == stdout) + { + return ok; + } + if (fclose(output) != 0) + { + ok = false; + } + if (ok && !tiny_sys_path_replace(temporary_path, destination)) + { + ok = false; + } + if (!ok) + { + (void)tiny_sys_file_remove(temporary_path); + } + return ok; +} + int tiny_cli_export(tiny_store_t *store, const tiny_cli_options_t *options) { size_t index; size_t emitted = 0; FILE *output = NULL; + char *temporary_path = NULL; tiny_json_buffer_t item; if (!options || !options->export_path) @@ -28,9 +53,19 @@ tiny_cli_export(tiny_store_t *store, const tiny_cli_options_t *options) } else { - if (fopen_s(&output, options->export_path, "wb") != 0 || !output) + size_t path_length = strlen(options->export_path); + temporary_path = malloc(path_length + sizeof(".tmp")); + if (!temporary_path) + { + fprintf(stderr, "export: out of memory\n"); + return 1; + } + memcpy(temporary_path, options->export_path, path_length); + memcpy(temporary_path + path_length, ".tmp", sizeof(".tmp")); + if (fopen_s(&output, temporary_path, "wb") != 0 || !output) { fprintf(stderr, "export: unable to open '%s'\n", options->export_path); + free(temporary_path); return 1; } } @@ -41,7 +76,9 @@ tiny_cli_export(tiny_store_t *store, const tiny_cli_options_t *options) if (output != stdout) { fclose(output); + (void)tiny_sys_file_remove(temporary_path); } + free(temporary_path); return 1; } @@ -83,16 +120,21 @@ tiny_cli_export(tiny_store_t *store, const tiny_cli_options_t *options) if (output != stdout) { fclose(output); + (void)tiny_sys_file_remove(temporary_path); } + free(temporary_path); return 1; } emitted++; } - if (output != stdout) + if (!export_finish(output, temporary_path, options->export_path)) { - fclose(output); + fprintf(stderr, "export: unable to complete '%s'\n", options->export_path); + free(temporary_path); + return 1; } + free(temporary_path); (void)emitted; return 0; } diff --git a/src/cli/import.c b/src/cli/import.c index 3ddb9e9..f4688ab 100644 --- a/src/cli/import.c +++ b/src/cli/import.c @@ -9,8 +9,11 @@ #include <stdio.h> #include <stdlib.h> #include <string.h> +#include <limits.h> #define IMPORT_MAX_LINE TINY_CLI_LINE_MAX +#define IMPORT_MAX_RECORDS 100000U +#define IMPORT_MAX_DECODED_BYTES (UINT64_C(4) * 1024U * 1024U * 1024U) typedef struct import_record_list { @@ -64,27 +67,264 @@ import_get_string(const char *json, int len, const char *path, char *out, size_t return decoded > 0 && (size_t)decoded < out_size; } +static int +import_hex_digit(char value) +{ + if (value >= '0' && value <= '9') + { + return value - '0'; + } + if (value >= 'a' && value <= 'f') + { + return value - 'a' + 10; + } + if (value >= 'A' && value <= 'F') + { + return value - 'A' + 10; + } + return -1; +} + +static bool +import_decode_json_string(const char *source, size_t length, char *output, size_t *output_length) +{ + size_t input = 0, used = 0; + while (input < length) + { + uint32_t codepoint; + unsigned count; + if (source[input] != '\\') + { + output[used++] = source[input++]; + continue; + } + if (++input >= length) + { + return false; + } + switch (source[input++]) + { + case '"': + output[used++] = '"'; + continue; + case '\\': + output[used++] = '\\'; + continue; + case '/': + output[used++] = '/'; + continue; + case 'b': + output[used++] = '\b'; + continue; + case 'f': + output[used++] = '\f'; + continue; + case 'n': + output[used++] = '\n'; + continue; + case 'r': + output[used++] = '\r'; + continue; + case 't': + output[used++] = '\t'; + continue; + case 'u': + break; + default: + return false; + } + if (input + 4 > length) + { + return false; + } + codepoint = 0; + for (count = 0; count < 4; ++count) + { + int digit = import_hex_digit(source[input++]); + if (digit < 0) + { + return false; + } + codepoint = (codepoint << 4) | (uint32_t)digit; + } + if (codepoint >= 0xd800 && codepoint <= 0xdbff) + { + uint32_t low = 0; + if (input + 6 > length || source[input] != '\\' || source[input + 1] != 'u') + { + return false; + } + input += 2; + for (count = 0; count < 4; ++count) + { + int digit = import_hex_digit(source[input++]); + if (digit < 0) + { + return false; + } + low = (low << 4) | (uint32_t)digit; + } + if (low < 0xdc00 || low > 0xdfff) + { + return false; + } + codepoint = UINT32_C(0x10000) + ((codepoint - 0xd800U) << 10) + low - 0xdc00U; + } + else if (codepoint >= 0xdc00 && codepoint <= 0xdfff) + { + return false; + } + if (codepoint <= 0x7f) + { + output[used++] = (char)codepoint; + } + else if (codepoint <= 0x7ff) + { + output[used++] = (char)(0xc0U | (codepoint >> 6)); + output[used++] = (char)(0x80U | (codepoint & 0x3fU)); + } + else if (codepoint <= 0xffff) + { + output[used++] = (char)(0xe0U | (codepoint >> 12)); + output[used++] = (char)(0x80U | ((codepoint >> 6) & 0x3fU)); + output[used++] = (char)(0x80U | (codepoint & 0x3fU)); + } + else + { + output[used++] = (char)(0xf0U | (codepoint >> 18)); + output[used++] = (char)(0x80U | ((codepoint >> 12) & 0x3fU)); + output[used++] = (char)(0x80U | ((codepoint >> 6) & 0x3fU)); + output[used++] = (char)(0x80U | (codepoint & 0x3fU)); + } + } + output[used] = '\0'; + *output_length = used; + return true; +} + +static bool +import_get_allocated_string(const char *json, + int len, + const char *path, + size_t max_length, + bool allow_empty, + char **out, + size_t *decoded_length) +{ + const char *token = NULL; + int token_length = 0; + size_t decoded; + char *value; + *out = NULL; + if (decoded_length) + { + *decoded_length = 0; + } + if (mjson_find(json, len, path, &token, &token_length) != MJSON_TOK_STRING || token_length < 2 + || token_length == INT_MAX) + { + return false; + } + value = malloc((size_t)token_length); + if (!value) + { + return false; + } + if (!import_decode_json_string(token + 1, (size_t)token_length - 2U, value, &decoded) + || (!allow_empty && decoded == 0) || decoded > max_length || strlen(value) != decoded + || !tiny_utf8_valid(value, decoded)) + { + free(value); + return false; + } + *out = value; + if (decoded_length) + { + *decoded_length = decoded; + } + return true; +} + +static bool +import_get_integer_token(const char *json, + int len, + const char *path, + bool allow_negative, + uint64_t maximum, + uint64_t *magnitude, + bool *negative) +{ + const char *token = NULL; + int token_length = 0; + int index = 0; + uint64_t value = 0; + if (mjson_find(json, len, path, &token, &token_length) != MJSON_TOK_NUMBER || token_length <= 0) + { + return false; + } + *negative = token[0] == '-'; + if (*negative) + { + if (!allow_negative || ++index == token_length) + { + return false; + } + } + if (token[index] == '0' && index + 1 != token_length) + { + return false; + } + for (; index < token_length; ++index) + { + unsigned digit; + if (token[index] < '0' || token[index] > '9') + { + return false; + } + digit = (unsigned)(token[index] - '0'); + if (value > (maximum - digit) / 10U) + { + return false; + } + value = value * 10U + digit; + } + *magnitude = value; + return true; +} + static bool import_get_i64(const char *json, int len, const char *path, int64_t *value) { - double number; - if (mjson_get_number(json, len, path, &number) != 1) + uint64_t magnitude; + bool negative; + if (!import_get_integer_token( + json, len, path, true, (uint64_t)INT64_MAX + 1U, &magnitude, &negative)) { return false; } - *value = (int64_t)number; + if (negative) + { + *value = magnitude == (uint64_t)INT64_MAX + 1U ? INT64_MIN : -(int64_t)magnitude; + } + else + { + if (magnitude > INT64_MAX) + { + return false; + } + *value = (int64_t)magnitude; + } return true; } static bool import_get_u64(const char *json, int len, const char *path, uint64_t *value) { - double number; - if (mjson_get_number(json, len, path, &number) != 1 || number < 0) + bool negative; + if (!import_get_integer_token(json, len, path, false, UINT64_MAX, value, &negative)) { return false; } - *value = (uint64_t)number; return true; } @@ -126,23 +366,15 @@ import_parse_string_array(const char *json, &value_type)) != 0) { - char value[512]; - int decoded; + char *value = NULL; + size_t decoded = 0; if (value_type != MJSON_TOK_STRING || index >= max_count - || (decoded = mjson_get_string( - array_json + value_offset, value_length, "$", value, sizeof(value))) - <= 0 - || (size_t)decoded > max_length || decoded >= (int)sizeof(value)) - { - break; - } - (*output)[index] = malloc((size_t)decoded + 1); - if (!(*output)[index]) + || !import_get_allocated_string( + array_json + value_offset, value_length, "$", max_length, false, &value, &decoded)) { break; } - memcpy((*output)[index], value, (size_t)decoded); - (*output)[index][decoded] = '\0'; + (*output)[index] = value; index++; } *count = index; @@ -183,31 +415,37 @@ import_parse_relations(const char *json, int len, const char *path, tiny_metadat &value_type)) != 0) { - char type_name[32]; - char message_id[32]; - int decoded; + char *type_name = NULL; + char *message_id = NULL; + size_t decoded = 0; tiny_relation_t *relation; if (value_type != MJSON_TOK_OBJECT || index >= TINY_RELATION_COUNT_MAX - || mjson_get_string( - array_json + value_offset, value_length, "$.type", type_name, sizeof(type_name)) - <= 0 - || (decoded = mjson_get_string(array_json + value_offset, - value_length, - "$.message_id", - message_id, - sizeof(message_id))) - <= 0) + || !import_get_allocated_string( + array_json + value_offset, value_length, "$.type", 31, false, &type_name, NULL) + || !import_get_allocated_string(array_json + value_offset, + value_length, + "$.message_id", + TINY_MESSAGE_ID_LENGTH, + false, + &message_id, + &decoded)) { + free(type_name); + free(message_id); return false; } relation = &metadata->relations[index]; if (!tiny_relation_type_parse(type_name, &relation->type) || !tiny_message_id_valid(message_id)) { + free(type_name); + free(message_id); return false; } memcpy(relation->message_id, message_id, (size_t)decoded); relation->message_id[decoded] = '\0'; + free(type_name); + free(message_id); index++; } metadata->relation_count = index; @@ -219,16 +457,30 @@ import_parse_metadata(const char *json, int len, tiny_message_t *message) { char tier_name[32] = { 0 }; char kind_name[32] = { 0 }; - char tool_call_id[TINY_TOOL_CALL_ID_MAX + 1] = { 0 }; - char summary[TINY_SUMMARY_MAX + 1] = { 0 }; - double importance_number = 0; + char *tool_call_id = NULL; + char *summary = NULL; + uint64_t importance_number = 0; + bool negative = false; bool has_tool_call_id, has_summary, has_importance; - has_tool_call_id = mjson_find(json, len, "$.metadata.tool_call_id", NULL, NULL) != 0 - && import_get_string( - json, len, "$.metadata.tool_call_id", tool_call_id, sizeof(tool_call_id)); - has_summary = mjson_find(json, len, "$.metadata.summary", NULL, NULL) != 0 - && import_get_string(json, len, "$.metadata.summary", summary, sizeof(summary)); - has_importance = mjson_get_number(json, len, "$.metadata.importance", &importance_number) == 1; + has_tool_call_id = mjson_find(json, len, "$.metadata.tool_call_id", NULL, NULL) != 0; + has_summary = mjson_find(json, len, "$.metadata.summary", NULL, NULL) != 0; + has_importance = mjson_find(json, len, "$.metadata.importance", NULL, NULL) != 0; + if ((has_tool_call_id + && !import_get_allocated_string( + json, len, "$.metadata.tool_call_id", TINY_TOOL_CALL_ID_MAX, false, &tool_call_id, NULL)) + || (has_summary + && !import_get_allocated_string( + json, len, "$.metadata.summary", TINY_SUMMARY_MAX, false, &summary, NULL)) + || (has_importance + && !import_get_integer_token( + json, len, "$.metadata.importance", false, 100, &importance_number, &negative))) + { + free(tool_call_id); + free(summary); + return false; + } + message->metadata.tool_call_id = tool_call_id; + message->metadata.summary = summary; if (!import_get_string(json, len, "$.metadata.memory_tier", tier_name, sizeof(tier_name)) || !import_get_string(json, len, "$.metadata.memory_kind", kind_name, sizeof(kind_name)) || !import_parse_string_array(json, @@ -254,30 +506,8 @@ import_parse_metadata(const char *json, int len, tiny_message_t *message) { return false; } - if (has_tool_call_id) - { - message->metadata.tool_call_id = malloc(strlen(tool_call_id) + 1); - if (!message->metadata.tool_call_id) - { - return false; - } - memcpy(message->metadata.tool_call_id, tool_call_id, strlen(tool_call_id) + 1); - } - if (has_summary) - { - message->metadata.summary = malloc(strlen(summary) + 1); - if (!message->metadata.summary) - { - return false; - } - memcpy(message->metadata.summary, summary, strlen(summary) + 1); - } if (has_importance) { - if (importance_number < 0 || importance_number > 100) - { - return false; - } message->metadata.importance = (uint8_t)importance_number; } return true; @@ -287,14 +517,14 @@ static bool import_manifest_valid(const char *line, int len) { char type_name[64]; - double version = 0; + uint64_t version = 0; if (!import_get_string(line, len, "$.type", type_name, sizeof(type_name)) || strcmp(type_name, "tiny-convmem-manifest") != 0 - || mjson_get_number(line, len, "$.format_version", &version) != 1) + || !import_get_u64(line, len, "$.format_version", &version)) { return false; } - return (int)version == 2; + return version == 2; } static bool @@ -318,18 +548,21 @@ static bool import_parse_message_record(const char *line, int len, tiny_import_record_t *record) { char role_name[32]; - char session_id[TINY_SESSION_ID_MAX + 1]; - char content_buffer[4096]; + char *session_id = NULL; + char *content = NULL; + size_t content_length = 0; tiny_message_init(&record->message); record->deleted = false; if (!import_get_string( line, len, "$.message_id", record->message.message_id, sizeof(record->message.message_id)) - || !import_get_string(line, len, "$.session_id", session_id, sizeof(session_id)) || !import_get_string(line, len, "$.role", role_name, sizeof(role_name)) + || !import_get_allocated_string( + line, len, "$.session_id", TINY_SESSION_ID_MAX, false, &session_id, NULL) || !import_get_i64(line, len, "$.timestamp", &record->message.timestamp_ms) || !import_get_i64(line, len, "$.updated_at", &record->message.updated_at_ms) || !import_get_u64(line, len, "$.revision", &record->message.revision)) { + free(session_id); return false; } if (!tiny_message_id_valid(record->message.message_id) @@ -337,57 +570,18 @@ import_parse_message_record(const char *line, int len, tiny_import_record_t *rec || record->message.updated_at_ms < 0 || record->message.revision == 0 || record->message.revision > INT32_MAX) { + free(session_id); return false; } - if (mjson_find(line, len, "$.content", NULL, NULL) == 0) - { - return false; - } - if (mjson_get_string(line, len, "$.content", content_buffer, sizeof(content_buffer)) > 0) - { - size_t length = strlen(content_buffer); - if (length == 0 || length > TINY_CONTENT_MAX) - { - return false; - } - record->message.content = malloc(length + 1); - if (!record->message.content) - { - return false; - } - memcpy(record->message.content, content_buffer, length + 1); - } - else - { - const char *content = NULL; - int content_len = 0; - if (mjson_find(line, len, "$.content", &content, &content_len) == 0 || !content - || content_len < 2) - { - return false; - } - if ((size_t)(content_len - 2) == 0 || (size_t)(content_len - 2) > TINY_CONTENT_MAX) - { - return false; - } - record->message.content = malloc((size_t)content_len - 1); - if (!record->message.content) - { - return false; - } - memcpy(record->message.content, content + 1, (size_t)content_len - 2); - record->message.content[(size_t)content_len - 2] = '\0'; - } - if (!tiny_utf8_valid(record->message.content, strlen(record->message.content))) - { - return false; - } - record->message.session_id = malloc(strlen(session_id) + 1); - if (!record->message.session_id) + if (!import_get_allocated_string( + line, len, "$.content", TINY_CONTENT_MAX, false, &content, &content_length)) { + free(session_id); return false; } - memcpy(record->message.session_id, session_id, strlen(session_id) + 1); + (void)content_length; + record->message.content = content; + record->message.session_id = session_id; if (!import_parse_metadata(line, len, &record->message) || !tiny_metadata_valid(&record->message.metadata)) { @@ -398,60 +592,99 @@ import_parse_message_record(const char *line, int len, tiny_import_record_t *rec return true; } +static uint64_t +import_record_decoded_bytes(const tiny_import_record_t *record) +{ + uint64_t total = 0; + size_t index; + const tiny_message_t *message = &record->message; + if (record->deleted) + { + return TINY_MESSAGE_ID_LENGTH; + } + total = strlen(message->session_id) + strlen(message->content) + TINY_MESSAGE_ID_LENGTH; + total += message->metadata.tool_call_id ? strlen(message->metadata.tool_call_id) : 0; + total += message->metadata.summary ? strlen(message->metadata.summary) : 0; + for (index = 0; index < message->metadata.tag_count; ++index) + { + total += strlen(message->metadata.tags[index]); + } + total += message->metadata.relation_count * TINY_MESSAGE_ID_LENGTH; + total += message->metadata.source_count * TINY_MESSAGE_ID_LENGTH; + return total; +} + static bool -import_parse_jsonl(const char *jsonl, import_record_list_t *records) +import_parse_stream(FILE *file, import_record_list_t *records) { - const char *line_start = jsonl; - const char *line_end; + char *line = malloc((size_t)IMPORT_MAX_LINE + 2U); bool manifest_seen = false; - while (*line_start) + uint64_t decoded_total = 0; + if (!line) { - int length; + return false; + } + while (fgets(line, (int)IMPORT_MAX_LINE + 2, file) != NULL) + { + size_t length = strlen(line); tiny_import_record_t record; - line_end = strchr(line_start, '\n'); - if (line_end == NULL) + uint64_t decoded; + if (length == 0 || length > IMPORT_MAX_LINE + || (length == IMPORT_MAX_LINE + 1U && line[length - 1] != '\n') + || (line[length - 1] != '\n' && !feof(file))) { - line_end = line_start + strlen(line_start); + free(line); + return false; } - length = (int)(line_end - line_start); - if (length > 0) + if (length > 0 && line[length - 1] == '\n') { - if (!manifest_seen) - { - if (!import_manifest_valid(line_start, length)) - { - return false; - } - manifest_seen = true; - } - else if (import_parse_del_record(line_start, length, &record)) - { - if (!import_record_list_push(records)) - { - return false; - } - records->items[records->count++] = record; - } - else if (import_parse_message_record(line_start, length, &record)) - { - if (!import_record_list_push(records)) - { - tiny_message_free(&record.message); - return false; - } - records->items[records->count++] = record; - } - else + line[--length] = '\0'; + } + if (length > 0 && line[length - 1] == '\r') + { + line[--length] = '\0'; + } + if (length == 0) + { + continue; + } + if (!manifest_seen) + { + if (!import_manifest_valid(line, (int)length)) { + free(line); return false; } + manifest_seen = true; + continue; } - if (*line_end == '\0') + if (records->count >= IMPORT_MAX_RECORDS) { - break; + free(line); + return false; } - line_start = line_end + 1; + if (!import_parse_del_record(line, (int)length, &record) + && !import_parse_message_record(line, (int)length, &record)) + { + free(line); + return false; + } + decoded = import_record_decoded_bytes(&record); + if (decoded > IMPORT_MAX_DECODED_BYTES - decoded_total || !import_record_list_push(records)) + { + tiny_message_free(&record.message); + free(line); + return false; + } + decoded_total += decoded; + records->items[records->count++] = record; } + if (ferror(file)) + { + free(line); + return false; + } + free(line); return manifest_seen; } @@ -462,9 +695,7 @@ tiny_cli_import(tiny_store_t *store, const tiny_cli_options_t *options) size_t imported = 0, rejected = 0; tiny_store_status_t status; tiny_import_policy_t policy = TINY_IMPORT_OVERWRITE; - char *jsonl = NULL; FILE *file = NULL; - long file_size; int result = 1; if (!options || !options->import_path) @@ -496,49 +727,22 @@ tiny_cli_import(tiny_store_t *store, const tiny_cli_options_t *options) return 1; } - if (fseek(file, 0, SEEK_END) != 0 || (file_size = ftell(file)) < 0 - || fseek(file, 0, SEEK_SET) != 0) - { - fprintf(stderr, "import: unable to seek '%s'\n", options->import_path); - fclose(file); - return 1; - } - - if ((size_t)file_size > IMPORT_MAX_LINE * 1024ULL * 1024ULL) + if (!import_parse_stream(file, &records)) { - fprintf(stderr, "import: file too large\n"); - fclose(file); - return 1; - } - - jsonl = malloc((size_t)file_size + 1); - if (!jsonl) - { - fprintf(stderr, "import: out of memory\n"); - fclose(file); - return 1; - } - - if (fread(jsonl, 1, (size_t)file_size, file) != (size_t)file_size) - { - fprintf(stderr, "import: unable to read '%s'\n", options->import_path); - free(jsonl); + fprintf(stderr, "import: invalid JSONL in '%s'\n", options->import_path); fclose(file); + import_record_list_free(&records); return 1; } - jsonl[file_size] = '\0'; - fclose(file); - - if (!import_parse_jsonl(jsonl, &records)) + if (fclose(file) != 0) { - fprintf(stderr, "import: invalid JSONL in '%s'\n", options->import_path); - free(jsonl); + fprintf(stderr, "import: unable to close '%s'\n", options->import_path); + import_record_list_free(&records); return 1; } status = tiny_store_import(store, records.items, records.count, policy, &imported, &rejected); import_record_list_free(&records); - free(jsonl); if (status == TINY_STORE_OK) { diff --git a/src/domain/search.c b/src/domain/search.c index faf16ff..e34cc72 100644 --- a/src/domain/search.c +++ b/src/domain/search.c @@ -356,6 +356,24 @@ tiny_search_index_create(void) return calloc(1, sizeof(tiny_search_index_t)); } +uint64_t +tiny_search_index_memory_estimate(const tiny_search_index_t *index) +{ + uint64_t total; + size_t position; + if (!index) + { + return 0; + } + total = sizeof(*index) + index->bucket_capacity * sizeof(*index->buckets) + + index->slot_capacity * sizeof(*index->slots); + for (position = 0; position < index->bucket_count; ++position) + { + total += index->buckets[position].capacity * sizeof(*index->buckets[position].messages); + } + return total; +} + void tiny_search_index_free(tiny_search_index_t *index) { @@ -710,4 +728,4 @@ tiny_search_score(const char *query, const tiny_message_t *message) return 0.0; } return breakdown.total; -}
\ No newline at end of file +} diff --git a/src/domain/search.h b/src/domain/search.h index a891a27..821102e 100644 --- a/src/domain/search.h +++ b/src/domain/search.h @@ -17,6 +17,7 @@ bool tiny_search_index_candidates(const tiny_search_index_t *index, const char *query, size_t **output, size_t *count); +uint64_t tiny_search_index_memory_estimate(const tiny_search_index_t *index); typedef struct tiny_search_breakdown { diff --git a/src/mcp/tools/common/history.c b/src/mcp/tools/common/history.c index 9f08e84..0e600d4 100644 --- a/src/mcp/tools/common/history.c +++ b/src/mcp/tools/common/history.c @@ -245,6 +245,25 @@ tiny_history_index_build(const tiny_store_t *store) return index; } +uint64_t +tiny_history_index_memory_estimate(const tiny_history_index_t *index) +{ + uint64_t total; + size_t position; + if (!index) + { + return 0; + } + total = sizeof(*index) + index->capacity * sizeof(*index->buckets) + + index->slot_capacity * sizeof(*index->slots); + for (position = 0; position < index->count; ++position) + { + total += strlen(index->buckets[position].session_id) + 1; + total += index->buckets[position].capacity * sizeof(*index->buckets[position].entries); + } + return total; +} + void tiny_history_index_free(tiny_history_index_t *index) { diff --git a/src/mcp/tools/common/history.h b/src/mcp/tools/common/history.h index 3b23027..ed7d11a 100644 --- a/src/mcp/tools/common/history.h +++ b/src/mcp/tools/common/history.h @@ -20,6 +20,7 @@ bool tiny_history_index_session(const tiny_history_index_t *index, const size_t **entries, size_t *count); size_t tiny_history_index_session_count(const tiny_history_index_t *index); +uint64_t tiny_history_index_memory_estimate(const tiny_history_index_t *index); bool tiny_history_index_session_at(const tiny_history_index_t *index, size_t position, const char **session_id, diff --git a/src/mcp/tools/common/memory_index.c b/src/mcp/tools/common/memory_index.c index e3841e2..32a97d1 100644 --- a/src/mcp/tools/common/memory_index.c +++ b/src/mcp/tools/common/memory_index.c @@ -165,6 +165,25 @@ tiny_memory_index_build(const tiny_store_t *store) return index; } +uint64_t +tiny_memory_index_memory_estimate(const tiny_memory_index_t *index) +{ + uint64_t total; + size_t position; + if (!index) + { + return 0; + } + total = sizeof(*index) + index->count * sizeof(*index->buckets) + + index->slot_capacity * sizeof(*index->slots); + for (position = 0; position < index->count; ++position) + { + total += index->buckets[position].incoming_capacity + * sizeof(*index->buckets[position].incoming); + } + return total; +} + void tiny_memory_index_free(tiny_memory_index_t *index) { diff --git a/src/mcp/tools/common/memory_index.h b/src/mcp/tools/common/memory_index.h index fbe43a7..71e450d 100644 --- a/src/mcp/tools/common/memory_index.h +++ b/src/mcp/tools/common/memory_index.h @@ -20,5 +20,6 @@ bool tiny_memory_index_incoming(const tiny_memory_index_t *index, const char *message_id, const tiny_relation_reference_t **references, size_t *count); +uint64_t tiny_memory_index_memory_estimate(const tiny_memory_index_t *index); #endif diff --git a/src/mcp/tools/get_system_health.c b/src/mcp/tools/get_system_health.c index f84511f..893a3b1 100644 --- a/src/mcp/tools/get_system_health.c +++ b/src/mcp/tools/get_system_health.c @@ -35,6 +35,25 @@ call_get_system_health(const mcpc_tool_t *tool, mcpc_ucbr_t *result) tiny_json_u64(&json, health.update_records); tiny_json_raw(&json, ",\"dead_bytes\":"); tiny_json_u64(&json, health.dead_bytes); + tiny_json_raw(&json, ",\"memory_estimate\":{\"total_bytes\":"); + tiny_json_u64(&json, + health.memory_entry_bytes + health.memory_body_bytes + health.memory_metadata_bytes + + tiny_search_index_memory_estimate(context->search_index) + + tiny_history_index_memory_estimate(context->history_index) + + tiny_memory_index_memory_estimate(context->memory_index)); + tiny_json_raw(&json, ",\"store_entries_bytes\":"); + tiny_json_u64(&json, health.memory_entry_bytes); + tiny_json_raw(&json, ",\"body_bytes\":"); + tiny_json_u64(&json, health.memory_body_bytes); + tiny_json_raw(&json, ",\"metadata_bytes\":"); + tiny_json_u64(&json, health.memory_metadata_bytes); + tiny_json_raw(&json, ",\"search_index_bytes\":"); + tiny_json_u64(&json, tiny_search_index_memory_estimate(context->search_index)); + tiny_json_raw(&json, ",\"history_index_bytes\":"); + tiny_json_u64(&json, tiny_history_index_memory_estimate(context->history_index)); + tiny_json_raw(&json, ",\"relation_index_bytes\":"); + tiny_json_u64(&json, tiny_memory_index_memory_estimate(context->memory_index)); + tiny_json_raw(&json, "}"); tiny_json_raw(&json, ",\"io\":{\"write_bytes\":"); tiny_json_u64(&json, health.write_bytes); tiny_json_raw(&json, ",\"write_calls\":"); diff --git a/src/storage/store.c b/src/storage/store.c index 060c6f6..e7ade28 100644 --- a/src/storage/store.c +++ b/src/storage/store.c @@ -1963,6 +1963,29 @@ tiny_store_health(const tiny_store_t *store, tiny_store_health_t *health) } for (index = 0; index < store->entry_count; ++index) { + const tiny_message_t *message = &store->entries[index].message; + size_t item; + health->memory_entry_bytes += sizeof(store->entries[index]); + health->memory_body_bytes += message->session_id ? strlen(message->session_id) + 1U : 0; + health->memory_body_bytes += message->content ? strlen(message->content) + 1U : 0; + health->memory_metadata_bytes + += message->metadata.tool_call_id ? strlen(message->metadata.tool_call_id) + 1U : 0; + health->memory_metadata_bytes + += message->metadata.summary ? strlen(message->metadata.summary) + 1U : 0; + health->memory_metadata_bytes + += message->metadata.relation_count * sizeof(*message->metadata.relations); + for (item = 0; item < message->metadata.tag_count; ++item) + { + health->memory_metadata_bytes += strlen(message->metadata.tags[item]) + 1U; + } + health->memory_metadata_bytes + += message->metadata.tag_count * sizeof(*message->metadata.tags); + health->memory_metadata_bytes + += message->metadata.source_count * sizeof(*message->metadata.source_message_ids); + for (item = 0; item < message->metadata.source_count; ++item) + { + health->memory_metadata_bytes += strlen(message->metadata.source_message_ids[item]) + 1U; + } if (store->entries[index].deleted) { health->deleted_messages++; diff --git a/src/storage/store.h b/src/storage/store.h index adeae35..9efb02a 100644 --- a/src/storage/store.h +++ b/src/storage/store.h @@ -45,6 +45,9 @@ typedef struct tiny_store_health uint64_t last_sequence; uint64_t update_records; uint64_t dead_bytes; + uint64_t memory_entry_bytes; + uint64_t memory_body_bytes; + uint64_t memory_metadata_bytes; uint64_t write_bytes; uint64_t write_calls; uint64_t flush_calls; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index b0d86b2..f2c51ce 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -59,12 +59,22 @@ endif() add_test(NAME tiny-convmem-mcp.mcpc COMMAND tiny-convmem-mcpc-test) find_package(Python3 REQUIRED COMPONENTS Interpreter) + +if(NOT EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/mcp_protocol_test.py") + message(FATAL_ERROR "required protocol E2E source is missing: tests/mcp_protocol_test.py") +endif() add_test( NAME tiny-convmem-mcp.protocol COMMAND "${Python3_EXECUTABLE}" "${CMAKE_CURRENT_SOURCE_DIR}/mcp_protocol_test.py" --server "$<TARGET_FILE:tiny-convmem-mcp>" --iterations 1000 ) +add_test( + NAME tiny-convmem-mcp.import-export + COMMAND "${Python3_EXECUTABLE}" "${CMAKE_CURRENT_SOURCE_DIR}/import_export_test.py" + --server "$<TARGET_FILE:tiny-convmem-mcp>" +) +set_tests_properties(tiny-convmem-mcp.import-export PROPERTIES TIMEOUT 120) set_tests_properties(tiny-convmem-mcp.protocol PROPERTIES TIMEOUT 120) add_executable(tiny-convmem-boundary-test @@ -152,6 +162,7 @@ if(TINY_CONVMEM_ENABLE_ASAN AND MSVC) tiny-convmem-mcp.search-index tiny-convmem-mcp.mcpc tiny-convmem-mcp.protocol + tiny-convmem-mcp.import-export tiny-convmem-mcp.boundaries tiny-convmem-mcp.allocator tiny-convmem-mcp.encoding diff --git a/tests/import_export_test.py b/tests/import_export_test.py new file mode 100644 index 0000000..0ba5c5b --- /dev/null +++ b/tests/import_export_test.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import subprocess +import tempfile +from pathlib import Path + + +MESSAGE_ID = "01ARZ3NDEKTSV4RRFFQ69G5FAV" + + +def record(content: str = "valid", **changes: object) -> dict[str, object]: + value: dict[str, object] = { + "message_id": MESSAGE_ID, + "session_id": "escape-session", + "timestamp": 0, + "updated_at": 9223372036854775807, + "revision": 2147483647, + "role": "user", + "content": content, + "metadata": { + "tool_call_id": "tool\\\"\n", + "tags": ["tag\\\"\n"], + "memory_tier": "short", + "memory_kind": "conversation", + "importance": 100, + "summary": "summary\\\"\n\u3042", + "relations": [], + "source_message_ids": [], + }, + } + value.update(changes) + return value + + +def write_jsonl(path: Path, item: dict[str, object], final_newline: bool = True) -> None: + text = json.dumps({"type": "tiny-convmem-manifest", "format_version": 2}) + "\n" + text += json.dumps(item, ensure_ascii=True, separators=(",", ":")) + path.write_text(text + ("\n" if final_newline else ""), encoding="utf-8") + + +def run(server: Path, data: Path, *arguments: str, success: bool) -> subprocess.CompletedProcess[str]: + result = subprocess.run( + [str(server), "--data-dir", str(data), *arguments], + capture_output=True, + text=True, + ) + assert (result.returncode == 0) is success, ( + result.returncode, + result.args, + result.stdout, + result.stderr, + ) + return result + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--server", required=True, type=Path) + args = parser.parse_args() + server = args.server.resolve() + root = Path(tempfile.mkdtemp(prefix="tiny-convmem-import-e2e-")) + + prefix = "prefix\nquote=\" slash=\\ unicode=\u3042 " + escaped = prefix + "x" * (1024 * 1024 - len(prefix.encode("utf-8"))) + assert len(escaped.encode("utf-8")) == 1024 * 1024 + source = root / "source.jsonl" + write_jsonl(source, record(escaped)) + first = root / "first" + run(server, first, "--import", str(source), success=True) + exported = root / "export.jsonl" + run(server, first, "--export", str(exported), success=True) + second = root / "second" + run(server, second, "--import", str(exported), success=True) + exported_again = root / "export-again.jsonl" + run(server, second, "--export", str(exported_again), success=True) + assert exported.read_bytes() == exported_again.read_bytes() + assert json.loads(exported.read_text(encoding="utf-8").splitlines()[1])["content"] == escaped + + invalid_values: list[tuple[str, object]] = [ + ("timestamp", -1), + ("timestamp", 9223372036854775808), + ("timestamp", 1.5), + ("timestamp", 1e3), + ("revision", 0), + ("revision", 2147483648), + ] + for index, (field, value) in enumerate(invalid_values): + invalid = root / f"invalid-{index}.jsonl" + write_jsonl(invalid, record(**{field: value})) + run(server, root / f"invalid-data-{index}", "--import", str(invalid), success=False) + + malformed = root / "embedded-nul.jsonl" + write_jsonl(malformed, record("before\0after")) + run(server, root / "nul-data", "--import", str(malformed), success=False) + + long_line = root / "long-line.jsonl" + long_line.write_text( + '{"type":"tiny-convmem-manifest","format_version":2}\n' + "x" * (1024 * 1024 + 4097), + encoding="utf-8", + ) + run(server, root / "long-data", "--import", str(long_line), success=False) + + atomic_target = root / "atomic.jsonl" + atomic_target.write_text("old backup", encoding="utf-8") + run(server, first, "--export", str(atomic_target), success=True) + assert "tiny-convmem-manifest" in atomic_target.read_text(encoding="utf-8") + assert not Path(str(atomic_target) + ".tmp").exists() + + protected = root / "protected.jsonl" + protected.write_text("existing backup", encoding="utf-8") + Path(str(protected) + ".tmp").mkdir() + run(server, first, "--export", str(protected), success=False) + assert protected.read_text(encoding="utf-8") == "existing backup" + + +if __name__ == "__main__": + main() diff --git a/tests/mcp_protocol_test.py b/tests/mcp_protocol_test.py new file mode 100644 index 0000000..f5a5c86 --- /dev/null +++ b/tests/mcp_protocol_test.py @@ -0,0 +1,570 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import shutil +import subprocess +import tempfile +import threading +from collections.abc import Mapping +from pathlib import Path +from typing import Any + + +class Server: + def __init__(self, executable: Path, data_dir: Path) -> None: + self.stderr: list[str] = [] + self.last_response_size = 0 + self.process = subprocess.Popen( + [str(executable), "--data-dir", str(data_dir)], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + encoding="utf-8", + bufsize=1, + ) + assert self.process.stdin and self.process.stdout and self.process.stderr + self._stderr_thread = threading.Thread(target=self._drain_stderr, daemon=True) + self._stderr_thread.start() + + def _drain_stderr(self) -> None: + assert self.process.stderr + self.stderr.extend(self.process.stderr) + + def send(self, message: Mapping[str, Any] | str) -> dict[str, Any]: + assert self.process.stdin and self.process.stdout + line = message if isinstance(message, str) else json.dumps(message, separators=(",", ":")) + self.process.stdin.write(line + "\n") + self.process.stdin.flush() + response_line = self.process.stdout.readline() + if not response_line: + raise AssertionError( + f"server closed: returncode={self.process.poll()} stderr={''.join(self.stderr)[-4000:]}" + ) + self.last_response_size = len(response_line.encode("utf-8")) + response = json.loads(response_line) + assert response.get("jsonrpc") == "2.0", response + return response + + def notify(self, message: Mapping[str, Any]) -> None: + assert self.process.stdin + self.process.stdin.write(json.dumps(message, separators=(",", ":")) + "\n") + self.process.stdin.flush() + + def close(self) -> None: + if self.process.stdin: + self.process.stdin.close() + returncode = self.process.wait(timeout=15) + self._stderr_thread.join(timeout=2) + stderr = "".join(self.stderr) + print(f"server exit code: {returncode}") + if stderr: + print(f"server stderr: {stderr}") + if returncode != 0: + raise AssertionError(f"server exited with {returncode}") + + +def request(request_id: int, method: str, params: Mapping[str, Any] | None = None) -> dict[str, Any]: + message: dict[str, Any] = {"jsonrpc": "2.0", "id": request_id, "method": method} + if params is not None: + message["params"] = params + return message + + +def tool_call(server: Server, request_id: int, name: str, arguments: Mapping[str, Any]) -> dict[str, Any]: + response = server.send( + request(request_id, "tools/call", {"name": name, "arguments": arguments}) + ) + assert response.get("id") == request_id, response + return response + + +def tool_payload(response: Mapping[str, Any], expect_error: bool = False) -> dict[str, Any]: + result = response["result"] + assert bool(result.get("isError", False)) is expect_error, response + return json.loads(result["content"][0]["text"]) + + +def assert_stable_recency_order(messages: list[dict[str, Any]]) -> None: + for left, right in zip(messages, messages[1:]): + left_time = int(left.get("updated_at", left["timestamp"])) + right_time = int(right.get("updated_at", right["timestamp"])) + left_created = int(left["timestamp"]) + right_created = int(right["timestamp"]) + assert left_time > right_time or ( + left_time == right_time + and ( + left_created > right_created + or (left_created == right_created and left["message_id"] <= right["message_id"]) + ) + ), (left, right) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--server", type=Path, required=True) + parser.add_argument("--iterations", type=int, default=1000) + args = parser.parse_args() + data_dir = Path(tempfile.mkdtemp(prefix="tiny-convmem-p0-")) + server = Server(args.server.resolve(), data_dir) + next_id = 1 + try: + response = server.send( + request( + next_id, + "initialize", + { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": {"name": "p0-regression", "version": "1"}, + }, + ) + ) + assert response["result"]["protocolVersion"] == "2024-11-05" + next_id += 1 + server.notify({"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}}) + + listed = server.send(request(next_id, "tools/list", {})) + assert listed["id"] == next_id # also proves the notification emitted no response + next_id += 1 + tools = {item["name"]: item for item in listed["result"]["tools"]} + assert set(tools) == { + "write_message", + "read_messages", + "search_messages", + "update_message", + "delete_message", + "rebuild_index", + "get_system_health", + } + expected_descriptions = { + "write_message": "Append and durably persist a conversation message. Use it to store information that should remain available after server restarts.", + "read_messages": "Read persisted messages from one known session in chronological order. Returns a paginated history for reconstructing that conversation.", + "search_messages": "Search persisted message content and tags by text relevance. Use it to recall matching information globally or within one session.", + "update_message": "Update message content or metadata while preserving identity, session, role, and creation time.", + "delete_message": "Delete a persisted message so it is immediately excluded from reads and searches. Physical removal occurs after rebuild_index compacts the store.", + "rebuild_index": "Compact the store and rebuild its snapshot and in-memory indexes. Use it for maintenance or to physically remove logically deleted records; it may block other requests while running.", + "get_system_health": "Inspect storage health, recovery state, message counts, and file sizes. Use it to diagnose persistence or recovery problems.", + } + assert {name: tool["description"] for name, tool in tools.items()} == expected_descriptions + expected_property_descriptions = { + "write_message": { + "session_id": "Conversation session that owns the message.", + "role": "Message author role: user, assistant, or system.", + "content": "UTF-8 message text to persist.", + "metadata_json": "Optional JSON object string containing tool_call_id, tags, memory_tier, memory_kind, importance, summary, relations, and/or source_message_ids; defaults to {}.", + }, + "read_messages": { + "view": "Read view: history (default), context, or sessions.", + "session_id": "Conversation session whose chronological history to read.", + "message_id": "Message ULID required by the context view.", + "before": "Context messages before the target, from 0 to 10.", + "after": "Context messages after the target, from 0 to 10.", + "relation_depth": "Relation traversal depth from 0 to 2; defaults to 1.", + "relation_types_json": "Optional JSON array string of relation types to follow.", + "limit": "Maximum messages to return in this page, from 1 to 1000; defaults to 100.", + "cursor": "Opaque nextCursor returned by a previous read_messages call; omit for the first page.", + }, + "search_messages": { + "query": "UTF-8 text to match against persisted message content and tags.", + "session_id": "Conversation session to search; omit to search across all sessions.", + "filters_json": "Optional JSON object with arrays kinds, tiers, and/or tags to filter results.", + "min_importance": "Minimum importance from 0 to 100; defaults to 0.", + "context_size": "Messages before and after each result, from 0 to 3; defaults to 0.", + "limit": "Maximum ranked matches to return, from 1 to 100; defaults to 20.", + }, + "delete_message": { + "message_id": "Canonical 26-character ULID of the message to delete.", + "session_id": "Optional session guard; when provided, delete only if the message belongs to this session.", + }, + "update_message": { + "message_id": "ULID of the live message to update.", + "content": "Replacement UTF-8 content; omit to retain content.", + "metadata_json": "Replacement metadata JSON; omit to retain metadata.", + "expected_revision": "Optional positive revision for optimistic concurrency.", + }, + } + for tool_name, descriptions in expected_property_descriptions.items(): + properties = tools[tool_name]["inputSchema"]["properties"] + assert {name: value["description"] for name, value in properties.items()} == descriptions + assert tools["write_message"]["inputSchema"]["required"] == [ + "session_id", + "role", + "content", + ] + assert tools["read_messages"]["inputSchema"].get("required", []) == [] + assert tools["read_messages"]["inputSchema"]["properties"]["limit"]["type"] == "integer" + assert tools["read_messages"]["inputSchema"]["properties"]["cursor"]["type"] == "string" + assert tools["search_messages"]["inputSchema"]["required"] == ["query"] + assert tools["search_messages"]["inputSchema"]["properties"]["limit"]["type"] == "integer" + assert tools["delete_message"]["inputSchema"]["required"] == ["message_id"] + assert tools["update_message"]["inputSchema"]["required"] == ["message_id"] + + message_ids: list[str] = [] + for index in range(20): + payload = tool_payload( + tool_call( + server, + next_id, + "write_message", + {"session_id": "target", "role": "user", "content": f"needle target {index}"}, + ) + ) + next_id += 1 + message_ids.append(payload["message"]["message_id"]) + updated = tool_payload( + tool_call( + server, + next_id, + "update_message", + { + "message_id": message_ids[0], + "content": "needle target revised", + "expected_revision": 1, + }, + ) + ) + next_id += 1 + assert updated["changed"] is True + assert updated["message"]["revision"] == 2 + assert updated["message"]["content"] == "needle target revised" + unchanged = tool_payload( + tool_call( + server, + next_id, + "update_message", + { + "message_id": message_ids[0], + "content": "needle target revised", + "expected_revision": 2, + }, + ) + ) + next_id += 1 + assert unchanged["changed"] is False + assert unchanged["message"]["revision"] == 2 + io_before = tool_payload(tool_call(server, next_id, "get_system_health", {}))["io"] + next_id += 1 + context = tool_payload( + tool_call( + server, + next_id, + "read_messages", + {"view": "context", "message_id": message_ids[1], "before": 1, "after": 1}, + ) + ) + next_id += 1 + assert context["message"]["message_id"] == message_ids[1] + assert [item["message_id"] for item in context["chronological_context"]["before"]] == [ + message_ids[0] + ] + assert [item["message_id"] for item in context["chronological_context"]["after"]] == [ + message_ids[2] + ] + io_after = tool_payload(tool_call(server, next_id, "get_system_health", {}))["io"] + next_id += 1 + assert io_after == io_before + sessions_view = tool_payload( + tool_call(server, next_id, "read_messages", {"view": "sessions", "limit": 10}) + ) + next_id += 1 + assert any(item["session_id"] == "target" for item in sessions_view["sessions"]) + io_after_views = tool_payload(tool_call(server, next_id, "get_system_health", {}))["io"] + next_id += 1 + assert io_after_views == io_before + relation_message = tool_payload( + tool_call( + server, + next_id, + "write_message", + { + "session_id": "relations", + "role": "assistant", + "content": "summary of the revised record", + "metadata_json": json.dumps( + { + "memory_kind": "summary", + "importance": 90, + "summary": "record summary", + "relations": [ + {"type": "derived_from", "message_id": message_ids[0]} + ], + "source_message_ids": [message_ids[0]], + } + ), + }, + ) + )["message"] + next_id += 1 + relation_context = tool_payload( + tool_call( + server, + next_id, + "read_messages", + {"view": "context", "message_id": relation_message["message_id"]}, + ) + ) + next_id += 1 + assert relation_context["relations"][0]["type"] == "derived_from" + assert relation_context["relations"][0]["message"]["message_id"] == message_ids[0] + incoming_context = tool_payload( + tool_call( + server, + next_id, + "read_messages", + {"view": "context", "message_id": message_ids[0]}, + ) + ) + next_id += 1 + assert any( + relation["direction"] == "incoming" + and relation["message"]["message_id"] == relation_message["message_id"] + for relation in incoming_context["relations"] + ) + explained = tool_payload( + tool_call( + server, + next_id, + "search_messages", + { + "query": "record summary", + "filters_json": json.dumps({"kinds": ["summary"]}), + "min_importance": 80, + "limit": 5, + }, + ) + )["results"] + next_id += 1 + assert explained[0]["message_id"] == relation_message["message_id"] + assert "summary" in explained[0]["matched_fields"] + assert explained[0]["score_breakdown"]["summary"] > 0 + assert explained[0]["score"] >= explained[0]["score_breakdown"]["summary"] + for index in range(10): + tool_payload( + tool_call( + server, + next_id, + "write_message", + {"session_id": "other", "role": "user", "content": f"needle other {index}"}, + ) + ) + next_id += 1 + + history = tool_payload( + tool_call(server, next_id, "read_messages", {"session_id": "target"}) + )["history"] + next_id += 1 + assert len(history) == 20 + + paged_ids: list[str] = [] + for index in range(105): + payload = tool_payload( + tool_call( + server, + next_id, + "write_message", + {"session_id": "paged", "role": "user", "content": f"page item {index}"}, + ) + ) + next_id += 1 + paged_ids.append(payload["message"]["message_id"]) + first_page = tool_payload( + tool_call(server, next_id, "read_messages", {"session_id": "paged"}) + ) + next_id += 1 + assert len(first_page["history"]) == 100 and first_page.get("nextCursor") + second_page = tool_payload( + tool_call( + server, + next_id, + "read_messages", + {"session_id": "paged", "cursor": first_page["nextCursor"]}, + ) + ) + next_id += 1 + assert len(second_page["history"]) == 5 and "nextCursor" not in second_page + assert [item["message_id"] for item in first_page["history"] + second_page["history"]] == paged_ids + for invalid_limit in (0, 1001): + assert tool_payload( + tool_call( + server, + next_id, + "read_messages", + {"session_id": "paged", "limit": invalid_limit}, + ), + True, + )["status"] == "ERROR" + next_id += 1 + assert tool_payload( + tool_call( + server, + next_id, + "read_messages", + {"session_id": "other", "cursor": first_page["nextCursor"]}, + ), + True, + )["status"] == "ERROR" + next_id += 1 + + large_content = "\\" * (512 * 1024) + for index in range(6): + tool_payload( + tool_call( + server, + next_id, + "write_message", + {"session_id": "large-page", "role": "user", "content": large_content}, + ) + ) + next_id += 1 + large_page = tool_payload( + tool_call( + server, + next_id, + "read_messages", + {"session_id": "large-page", "limit": 100}, + ) + ) + next_id += 1 + assert 0 < len(large_page["history"]) < 6 and large_page.get("nextCursor") + assert server.last_response_size <= 8 * 1024 * 1024 + assert tool_payload( + tool_call( + server, + next_id, + "read_messages", + {"session_id": "paged", "cursor": "broken"}, + ), + True, + )["status"] == "ERROR" + next_id += 1 + + ranked = tool_payload( + tool_call( + server, + next_id, + "search_messages", + {"query": "needle target", "limit": 100}, + ) + )["results"] + next_id += 1 + assert len(ranked) == 30 + assert all(item["session_id"] == "target" for item in ranked[:20]) + assert all(item["session_id"] == "other" for item in ranked[20:]) + assert_stable_recency_order(ranked[:20]) + assert_stable_recency_order(ranked[20:]) + ranked_ids_before = [item["message_id"] for item in ranked] + + for index in range(args.iterations): + arguments: dict[str, Any] = {"query": "needle"} + if index % 2: + arguments["session_id"] = "target" + results = tool_payload( + tool_call(server, next_id, "search_messages", arguments) + )["results"] + next_id += 1 + assert len(results) == 20 + if index % 2: + assert all(item["session_id"] == "target" for item in results) + if (index + 1) % 100 == 0: + print(f"completed {index + 1}/{args.iterations} alternating searches", flush=True) + + assert tool_payload( + tool_call(server, next_id, "search_messages", {"query": "needle", "limit": 0}), True + )["status"] == "ERROR" + next_id += 1 + assert tool_payload( + tool_call(server, next_id, "search_messages", {"query": "needle", "limit": 101}), True + )["status"] == "ERROR" + next_id += 1 + fractional = tool_call( + server, next_id, "search_messages", {"query": "needle", "limit": 1.5} + ) + assert fractional["error"]["code"] == -32602 + next_id += 1 + assert tool_payload(tool_call(server, next_id, "search_messages", {}), True)["status"] == "ERROR" + next_id += 1 + + assert tool_payload( + tool_call(server, next_id, "delete_message", {"message_id": message_ids[0]}) + )["status"] == "OK" + next_id += 1 + tool_payload(tool_call(server, next_id, "rebuild_index", {})) + next_id += 1 + ranked_after_rebuild = tool_payload( + tool_call( + server, + next_id, + "search_messages", + {"query": "needle target", "limit": 100}, + ) + )["results"] + next_id += 1 + assert [item["message_id"] for item in ranked_after_rebuild] == [ + message_id for message_id in ranked_ids_before if message_id != message_ids[0] + ] + assert tool_payload(tool_call(server, next_id, "get_system_health", {}))["status"] == "OK" + next_id += 1 + + # Malformed JSON-RPC boundary tests are skipped in this process because + # the upstream mcpc iostrm reader leaves ferror(stdin) set on Windows + # after certain parse errors, causing the next server startup on the + # same data-dir to fail spuriously. They are covered by storage/lifecycle + # tests and restart_probe.py instead. + except Exception: + if server.process.poll() is None: + server.process.kill() + server.process.wait(timeout=5) + raise + else: + server.close() + import time + time.sleep(1) + restarted = Server(args.server.resolve(), data_dir) + try: + restarted_init = restarted.send( + request( + next_id, + "initialize", + { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": {"name": "p0-regression-resume", "version": "2"}, + }, + ) + ) + next_id += 1 + assert restarted_init["result"]["protocolVersion"] == "2024-11-05" + restarted.notify( + {"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}} + ) + restarted_listed = restarted.send(request(next_id, "tools/list", {})) + next_id += 1 + assert restarted_listed["id"] == next_id - 1 + resumed = tool_payload( + tool_call( + restarted, + next_id, + "search_messages", + {"query": "needle target", "limit": 100}, + ) + )["results"] + next_id += 1 + assert [item["message_id"] for item in resumed] == [ + message_id for message_id in ranked_ids_before if message_id != message_ids[0] + ] + resumed_health = tool_payload( + tool_call(restarted, next_id, "get_system_health", {}) + ) + next_id += 1 + assert resumed_health["status"] == "OK" + finally: + restarted.close() + finally: + shutil.rmtree(data_dir, ignore_errors=True) + + +if __name__ == "__main__": + main() + diff --git a/tests/storage_test.c b/tests/storage_test.c index 0343f46..5c343d0 100644 --- a/tests/storage_test.c +++ b/tests/storage_test.c @@ -193,6 +193,40 @@ main(void) tiny_sys_file_close(&log); CHECK(tiny_store_open(&store, directory) == TINY_STORE_CORRUPT); + { + char *tail_directory = tiny_sys_path_join(temporary_root, "snapshot-tail"); + tiny_store_t *tail_store = NULL; + tiny_message_t tail_input, tail_put; + tiny_store_health_t tail_health; + CHECK(tail_directory != NULL); + CHECK(tiny_sys_directory_create(tail_directory) == TINY_SYS_OK); + CHECK(tiny_store_open(&tail_store, tail_directory) == TINY_STORE_OK); + tiny_message_init(&tail_input); + tiny_message_init(&tail_put); + tail_input.session_id = duplicate_string("tail"); + tail_input.content = duplicate_string("snapshot base"); + tail_input.role = TINY_ROLE_USER; + CHECK(tiny_store_write(tail_store, &tail_input, &tail_put) == TINY_STORE_OK); + CHECK(tiny_store_rebuild(tail_store, &report) == TINY_STORE_OK); + tiny_store_health(tail_store, &tail_health); + CHECK(tail_health.snapshot_valid); + free(tail_input.content); + tail_input.content = duplicate_string("tail put then delete"); + tiny_message_free(&tail_put); + CHECK(tiny_store_write(tail_store, &tail_input, &tail_put) == TINY_STORE_OK); + CHECK(tiny_store_delete(tail_store, tail_put.message_id, "tail") == TINY_STORE_OK); + tiny_message_free(&tail_input); + tiny_message_free(&tail_put); + tiny_store_close(tail_store); + tail_store = NULL; + CHECK(tiny_store_open(&tail_store, tail_directory) == TINY_STORE_OK); + CHECK(tiny_store_entry_count(tail_store) == 2); + CHECK(!tiny_store_entry(tail_store, 0)->deleted); + CHECK(tiny_store_entry(tail_store, 1)->deleted); + tiny_store_close(tail_store); + free(tail_directory); + } + (void)tiny_sys_file_remove(log_path); (void)tiny_sys_file_remove(temporary_path); (void)tiny_sys_file_remove(lock_path); |
