release prep: MIT license, wiki migration framework, release scaffolding

- LICENSE (MIT) + workspace license = MIT (down from MIT OR Apache-2.0)
- Wiki-structure migration framework: V06 wiki_migrations table,
  WikiMigration trait, registry, and run_pending runner (registry
  starts empty; v1 ships per-project layout natively)
- CHANGELOG.md, SECURITY.md, CONTRIBUTING.md
- bin/release (fmt/clippy/test/deny/audit -> bump -> tag; never pushes)
- .github release workflow + issue/PR templates
- README: bootstrap example defers to default workspace/project
- evals: inherit version/edition/rust-version from workspace

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
AkitaOnRails
2026-05-23 23:57:19 -03:00
co-authored by Claude Opus 4.7
parent f39886df79
commit d8e0542e6d
22 changed files with 1204 additions and 7 deletions
+28
View File
@@ -0,0 +1,28 @@
---
name: Bug report
about: Something is broken or behaving unexpectedly
labels: bug
---
**Version**
Output of `ai-memory --version`:
**What happened**
<!-- A clear description of the unexpected behaviour. -->
**What I expected**
<!-- What you expected to happen instead. -->
**Steps to reproduce**
1.
2.
3.
**Environment**
- OS:
- Docker? (yes / no, and image tag if yes):
- Agent (Claude Code / Codex / OpenCode / other):
- Transport (stdio / http):
**Relevant logs**
<!-- Paste the relevant portion of `ai-memory serve` output here. -->
+17
View File
@@ -0,0 +1,17 @@
---
name: Feature request
about: Suggest a new capability or improvement
labels: enhancement
---
**Problem this would solve**
<!-- What are you trying to do that you can't do today? -->
**Proposed solution**
<!-- Describe what you'd like to happen. Be as specific as you can. -->
**Alternatives considered**
<!-- Other approaches you've thought about or tried. -->
**Additional context**
<!-- Screenshots, links, related issues, etc. -->
+18
View File
@@ -0,0 +1,18 @@
## What changed
<!-- One paragraph or bullet list: the observable behaviour before vs. after. -->
## Why
<!-- The motivation: bug fix, new feature, performance, correctness. Link related issue if any. -->
## Test plan
- [ ] `cargo fmt --all -- --check` passes
- [ ] `cargo clippy --workspace --all-targets -- -D warnings` passes
- [ ] `cargo test --workspace` passes
- [ ] Manual test: <!-- describe what you ran and what you observed -->
## Notes for reviewers
<!-- Anything tricky, a design decision you made, or areas you'd like extra scrutiny on. -->
+63
View File
@@ -0,0 +1,63 @@
name: release
# Triggered when a version tag is pushed, e.g. git push origin v0.2.0.
# Builds the Docker image and pushes it to Docker Hub with both the
# version tag and `latest`.
#
# Required secrets (set in repo Settings → Secrets and variables → Actions):
# DOCKERHUB_USERNAME — Docker Hub account name
# DOCKERHUB_TOKEN — Docker Hub access token (read/write)
#
# The CI workflow already validates the binary (fmt / clippy / test).
# This workflow assumes CI passed on the same commit; it only builds
# and pushes the image.
on:
push:
tags:
- 'v*.*.*'
env:
CARGO_TERM_COLOR: always
jobs:
docker:
name: docker build + push
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
# Extract the version from the tag (strips the leading "v").
- name: Extract version
id: version
run: echo "VERSION=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: docker/Dockerfile
push: true
tags: |
${{ secrets.DOCKERHUB_USERNAME }}/ai-memory:latest
${{ secrets.DOCKERHUB_USERNAME }}/ai-memory:${{ steps.version.outputs.VERSION }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Smoke test the published image
run: |
docker pull "${{ secrets.DOCKERHUB_USERNAME }}/ai-memory:${{ steps.version.outputs.VERSION }}"
docker run --rm \
"${{ secrets.DOCKERHUB_USERNAME }}/ai-memory:${{ steps.version.outputs.VERSION }}" \
--version
+83
View File
@@ -0,0 +1,83 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
- Wiki-structure migration framework: `wiki_migrations` SQL table (V06),
`WikiMigration` trait, migration registry, and `run_pending` runner
invoked at server startup before the watcher starts.
- MCP read tools (`memory_query`, `memory_recent`, `memory_status`,
`memory_briefing`, `memory_explore`) accept an optional `project`
argument to target a specific project on a shared server.
### Fixed
- OpenCode hook events (`tool.execute.*`, `session.*`) were rejected with
"missing session_id" because OpenCode sends `sessionID` (capital `ID`)
and the extractor only matched `sessionId`. All spellings are now
accepted ([#1]).
- MCP read tools were locked to the server's static `--project` (default
`scratch`), so on a shared HTTP server they returned empty memory even
while hooks populated the correct per-cwd project. The hook router now
publishes the active project to a shared pointer that the read tools
use as their default; an explicit `project` argument overrides it ([#2]).
## [0.1.0] - 2026-XX-XX
### Added
- Per-project UUID-namespaced wiki layout: pages live at
`<wiki_root>/<workspace_id>/<project_id>/<page-path>`. Rename is now
a single column update; purge is `remove_dir_all` on the project dir.
- CLI becomes a thin HTTP client: `bootstrap`, `status`, `search`,
`reorg`, `lint`, `forget-sweep`, `embed`, `commit`, `backup`,
`write-page` all delegate to the running server via `/admin/*` routes.
The server is the sole writer of wiki + SQLite.
- `purge-project` command with cascade-delete indexes and per-project
isolation guard (refuses to delete files claimed by sibling projects).
- `rename-project` command: column-only rename, no file moves.
- `memory_install_self_routing` MCP tool: installs the agent-routing
snippet into CLAUDE.md / AGENTS.md / `.cursorrules` in one call.
- Read-only HTTP wiki browser (`/web`) with project tree, page view,
and full-text search.
- Bearer token auth (`AI_MEMORY_AUTH_TOKEN` / `generate-auth-token`),
Host-header allowlist, and 10 MB body cap for the HTTP server.
- `backup` / `restore` commands using `.tar.gz` archives with live-process
guard (refuses to run if another `ai-memory` is active on the same data dir).
- Per-cwd project routing in hooks: observations route to the project
matching the agent's working directory, not the server default.
- `opencode` / `openclaw` aliases for the OpenCode MCP client.
- Dockerised CLI wrapper (`bin/ai-memory`) with auto-restart for the
local container and nudge for remote upgrades.
- `bootstrap` serialises parallel runs to prevent duplicate project creation
and handles the case where the CWD has no git repo.
- Monthly log-md rotation to keep `log.md` from growing unbounded.
- `memory_consolidate` PreCompact checkpointing falls back to rule-based
summarisation when no LLM is configured.
- `docs/lifecycle-ops.md`: safety matrix for state-touching commands
(reset, restore, purge-project, rename-project).
- `docs/wiki-migrations.md`: when and how to write a wiki migration.
### Changed
- `bin/ai-memory` forwards `AI_MEMORY_SERVER_URL` and no longer creates
`-w` mount-conflict directories.
- `bootstrap` resolves the repo root via `libgit2`, removing the
`git` binary dependency.
- Admin routes consolidated: dry-run support, correct status codes,
deduplicated handlers.
- Host-header allowlist sourced from `Config.allowed_hosts`; logged at
startup so operators can verify the effective list.
### Fixed
- `AI_MEMORY_HOST_CWD` handling and dry-run no-project side effects.
- Web page view: strip leading H1 from body to prevent title duplication.
- `install-mcp` Codex config key was `bearer_token`, not
`http_headers` / `headers`.
- Consolidator used server startup default project instead of the
session's actual project.
[Unreleased]: https://github.com/akitaonrails/ai-memory/compare/v0.1.0...HEAD
[0.1.0]: https://github.com/akitaonrails/ai-memory/releases/tag/v0.1.0
+76
View File
@@ -0,0 +1,76 @@
# Contributing to ai-memory
## Dev setup
```bash
git clone https://github.com/akitaonrails/ai-memory
cd ai-memory
cargo build --workspace
cargo test --workspace
```
Rust 1.95 is required (pinned in `rust-toolchain.toml`). The build is
self-contained: SQLite is bundled via `rusqlite`'s `bundled` feature, and
`libgit2` is vendored via `git2`'s `vendored-libgit2` feature. No system
libraries need installing beyond a standard C toolchain.
## Required gates before every PR
All four must pass — the CI workflow enforces them and so does the `bin/release`
script:
```bash
cargo fmt --all -- --check # formatting
cargo clippy --workspace --all-targets -- -D warnings # lints
cargo test --workspace # tests
cargo deny check # dependency policy
```
If `cargo-deny` or `cargo-audit` are not installed:
```bash
cargo install cargo-deny cargo-audit
```
## Workflow rules (condensed from CLAUDE.md)
The full authoritative rules are in [`CLAUDE.md`](CLAUDE.md). Short version:
1. Work milestone by milestone. Do not start M(n+1) until every "Done when"
bullet in M(n) passes (see `docs/design-decisions.md`).
2. No dead code, no half-built features. Stubs are documented with
`// M<n> TODO` in the module doc-comment.
3. Write tests before claiming done. Parsers, ID derivation, and
retention/decay math especially.
4. Do not refactor outside the milestone. Only touch what the current
milestone requires.
5. Comments explain *why*, never *what*. No comments that restate the line
above them.
## Cross-cutting invariants
Never violate any of the invariants in `CLAUDE.md §Cross-cutting invariants`.
Highlights for contributors:
- All SQLite writes go through the single writer actor (`WriterHandle`).
- Config is read once at startup; never call `std::env::var` outside `Config::load`.
- Atomic file writes only: tmp + rename + fsync; never write in-place.
- Every wiki page is namespaced by `(workspace_id, project_id)`.
- The CLI is always a thin HTTP client to the running server — it never
opens the SQLite file or the wiki directory directly.
## Versioning and deprecation policy
This project follows [Semantic Versioning](https://semver.org/):
- **Patch** (`x.y.Z`): bug fixes that do not change public API or
on-disk format.
- **Minor** (`x.Y.0`): additive changes; new CLI subcommands, new MCP
tools, new config keys. Existing behaviour is preserved.
- **Major** (`X.0.0`): breaking changes. This includes on-disk format
changes that are not handled by a migration, removal of CLI subcommands,
or changes to the MCP tool schema that would break existing agents.
Breaking changes only ship in major releases. Deprecated items are
documented in the CHANGELOG under `### Deprecated` and removed no sooner
than the following major release.
Generated
+1
View File
@@ -237,6 +237,7 @@ dependencies = [
"ai-memory-llm",
"ai-memory-store",
"anyhow",
"async-trait",
"git2",
"jiff",
"notify",
+1 -1
View File
@@ -19,7 +19,7 @@ members = [
version = "0.1.0"
edition = "2024"
rust-version = "1.95"
license = "MIT OR Apache-2.0"
license = "MIT"
repository = "https://github.com/akitaonrails/ai-memory"
authors = ["Fabio Akita <boss@akitaonrails.com>"]
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Fabio Akita
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+7 -3
View File
@@ -12,7 +12,7 @@
[![status: v0.2 milestones complete](https://img.shields.io/badge/status-v0.2--complete-green)](docs/ARCHITECTURE.md)
[![Rust](https://img.shields.io/badge/rust-1.95+-blue)](rust-toolchain.toml)
[![License](https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-blue)](#license)
[![License](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
## What it is
@@ -381,9 +381,13 @@ solves that by LLM-summarising your existing `git log`, README,
# Requires an LLM provider configured on the server. Budget caps at
# 50k input tokens (~$0.05 with Claude Haiku 4.5).
export AI_MEMORY_SERVER_URL="http://localhost:49374"
ai-memory bootstrap --workspace homelab --project myproj
ai-memory bootstrap
```
The workspace defaults to `default` and the project defaults to the
current directory's basename — that's almost always what you want, so
omit `--workspace` / `--project` unless you're deliberately overriding.
Bootstrap produces a per-project `bootstrap.md` manifest (under
`<wiki>/<workspace>/<project>/`) listing every page generated + a
one-paragraph rationale. Run with `--dry-run` first to preview which
@@ -706,7 +710,7 @@ data-flow diagram + crate breakdown + cross-cutting invariants.
## License
Dual-licensed under MIT OR Apache-2.0.
MIT — see [LICENSE](LICENSE).
## Acknowledgements
+63
View File
@@ -0,0 +1,63 @@
# Security Policy
## Reporting a vulnerability
Please **do not open a public GitHub issue** for security vulnerabilities.
Report security issues by opening a [private security advisory](https://github.com/akitaonrails/ai-memory/security/advisories/new)
on GitHub. You will receive a response within 7 days. If the issue is confirmed
we will aim to release a patch within 30 days and credit you in the changelog
(unless you prefer to remain anonymous).
## Threat model
ai-memory is a **single-user, homelab tool**. The following describes what
the project is and is not designed to defend against.
### In scope
- **Local data confidentiality.** Wiki files and the SQLite database live
under a single data directory controlled by the operating-system user who
runs the server. We rely on filesystem permissions; no additional
encryption at rest is provided in v1.
- **Network exposure when binding to non-loopback addresses.** If you run
`ai-memory serve --bind 0.0.0.0:…` you are exposing the MCP and admin
routes to your local network. Protect this with:
- `AI_MEMORY_AUTH_TOKEN` / `ai-memory generate-auth-token` (bearer token
checked on every request).
- Firewall rules or a reverse proxy with TLS.
The server logs a loud warning if it detects a non-loopback bind without a
configured auth token.
- **Host-header DNS rebinding.** The HTTP server enforces an
`AI_MEMORY_ALLOWED_HOSTS` allowlist (defaulting to `127.0.0.1` and
`localhost`). Requests with a `Host` header not in the list are rejected
with 403.
- **Request body size.** Inbound HTTP bodies are capped at 10 MB to prevent
trivial memory exhaustion.
- **Per-project isolation.** Wiki files and SQLite rows are namespaced by
`(workspace_id, project_id)`. A purge operation for project A cannot
delete files that also belong to project B.
### Out of scope for v1
- **Multi-tenant authentication and authorisation.** There is one bearer
token (or none). There are no per-user roles or per-project ACLs.
- **Encryption at rest.** The data directory is a plain filesystem tree.
- **Remote sync security.** If you push the wiki git repository to a remote,
securing that channel is your responsibility (SSH keys, GitHub access
controls, etc.).
- **MCP tool-call injection via agent output.** The privacy strip
(`Sanitizer`) removes obvious credential patterns from hook payloads, but
it is not a comprehensive injection fence.
- **Denial of service.** The server is not hardened against a malicious local
actor hammering it with requests.
## Supported versions
Only the latest release receives security fixes. We do not backport to older
minor versions.
Executable
+197
View File
@@ -0,0 +1,197 @@
#!/usr/bin/env bash
# Cut a release of ai-memory.
#
# Usage: bin/release <version>
#
# Where <version> is a semver string without the leading "v", e.g. "0.2.0".
#
# What this script does:
# 1. Validates the version string.
# 2. Runs fmt / clippy / test gates.
# 3. Runs cargo-deny and cargo-audit (warn-only — a known advisory should
# not block a release; the operator reviews the output).
# 4. Updates [workspace.package] version in Cargo.toml.
# 5. Moves CHANGELOG.md [Unreleased] entries to a new [X.Y.Z] section
# dated today.
# 6. Commits both files as "release: vX.Y.Z".
# 7. Creates an annotated git tag vX.Y.Z whose message is the changelog
# entry for this version.
# 8. Prints next-step instructions: push the commit + tag, then deploy.
#
# The script intentionally does NOT push to git remote or to Docker Hub.
# Releases are operator-driven: review the tag locally before pushing.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
# ── argument validation ────────────────────────────────────────────────────
if [[ $# -ne 1 ]]; then
echo "Usage: bin/release <version> (e.g. bin/release 0.2.0)" >&2
exit 64
fi
VERSION="$1"
# Basic semver: X.Y.Z with optional pre-release / build metadata.
# We allow 0.1.0-rc.1 etc. but reject obviously wrong strings early.
if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][A-Za-z0-9.+_-]*)?$ ]]; then
echo "ERROR: '$VERSION' does not look like a semver string (expected X.Y.Z)" >&2
exit 1
fi
TAG="v${VERSION}"
TODAY="$(date -u +%Y-%m-%d)"
CARGO_TOML="$REPO_ROOT/Cargo.toml"
CHANGELOG="$REPO_ROOT/CHANGELOG.md"
echo "==> Releasing ${TAG} (dated ${TODAY})"
# ── quality gates ──────────────────────────────────────────────────────────
cd "$REPO_ROOT"
echo "==> cargo fmt --check"
cargo fmt --all -- --check
echo "==> cargo clippy"
cargo clippy --workspace --all-targets -- -D warnings
echo "==> cargo test"
cargo test --workspace
echo "==> cargo deny check (warn-only)"
if command -v cargo-deny &>/dev/null; then
cargo deny check || echo " [warn] cargo deny reported issues — review before pushing"
else
echo " [warn] cargo-deny not installed; skipping (install with: cargo install cargo-deny)"
fi
echo "==> cargo audit (warn-only)"
if command -v cargo-audit &>/dev/null; then
cargo audit || echo " [warn] cargo audit reported advisories — review before pushing"
else
echo " [warn] cargo-audit not installed; skipping (install with: cargo install cargo-audit)"
fi
# ── update Cargo.toml version ──────────────────────────────────────────────
echo "==> Updating Cargo.toml workspace version to ${VERSION}"
python3 - <<PYEOF
import re, pathlib, sys
path = pathlib.Path("$CARGO_TOML")
text = path.read_text()
# 1. Bump [workspace.package] version = "..."
new_text = re.sub(
r'(\[workspace\.package\].*?^version\s*=\s*")[^"]+(")',
r'\g<1>$VERSION\g<2>',
text,
count=1,
flags=re.MULTILINE | re.DOTALL,
)
if new_text == text:
sys.exit("ERROR: could not find [workspace.package] version in Cargo.toml")
# 2. Bump version constraints in [workspace.dependencies] for the intra-crate
# path deps (ai-memory-core, ai-memory-store, etc.). These always use the
# same version as the workspace package itself.
new_text = re.sub(
r'(ai-memory-\w+\s*=\s*\{[^}]*version\s*=\s*")[^"]+(")',
r'\g<1>$VERSION\g<2>',
new_text,
flags=re.MULTILINE,
)
path.write_text(new_text)
print(f" version -> $VERSION")
PYEOF
# ── update CHANGELOG.md ────────────────────────────────────────────────────
echo "==> Updating CHANGELOG.md"
python3 - <<PYEOF
import pathlib, re, sys
path = pathlib.Path("$CHANGELOG")
text = path.read_text()
unreleased_pattern = re.compile(
r'(## \[Unreleased\]\n)(.*?)(\n## \[)',
re.DOTALL,
)
m = unreleased_pattern.search(text)
if not m:
print(" [warn] no [Unreleased] section with content found; CHANGELOG not updated", file=sys.stderr)
sys.exit(0)
unreleased_body = m.group(2)
# Build new [Unreleased] (empty) + new version section.
new_version_header = f"## [$VERSION] - $TODAY"
new_block = (
"## [Unreleased]\n\n"
+ new_version_header + "\n"
+ unreleased_body
)
new_text = unreleased_pattern.sub(new_block + r"\n## [", text, count=1)
# Update the reference links at the bottom.
# Replace the Unreleased compare link to point from this version to HEAD.
new_text = re.sub(
r'^\[Unreleased\]:.*$',
f"[Unreleased]: https://github.com/akitaonrails/ai-memory/compare/{TAG}...HEAD\n"
f"[$VERSION]: https://github.com/akitaonrails/ai-memory/releases/tag/{TAG}",
new_text,
flags=re.MULTILINE,
)
path.write_text(new_text)
print(f" moved [Unreleased] entries to [{VERSION}] - $TODAY")
PYEOF
# ── commit + tag ───────────────────────────────────────────────────────────
echo "==> Committing release files"
git add "$CARGO_TOML" "$CHANGELOG" Cargo.lock
git commit -m "release: ${TAG}"
echo "==> Extracting changelog entry for tag message"
CHANGELOG_ENTRY="$(python3 - <<PYEOF
import pathlib, re
path = pathlib.Path("$CHANGELOG")
text = path.read_text()
# Extract the new version section body.
m = re.search(
r'## \[$VERSION\] - $TODAY\n(.*?)(?=\n## \[|\Z)',
text,
re.DOTALL,
)
print(m.group(1).strip() if m else "Release $TAG")
PYEOF
)"
echo "==> Creating annotated tag ${TAG}"
git tag -a "${TAG}" -m "$(printf '%s\n\n%s' "Release ${TAG}" "${CHANGELOG_ENTRY}")"
# ── next steps ─────────────────────────────────────────────────────────────
cat <<EOF
Release ${TAG} is ready locally. To publish:
git push && git push origin ${TAG}
Then deploy the Docker image:
./bin/deploy
EOF
@@ -0,0 +1,7 @@
-- Tracks applied wiki-structure migrations (parallel to refinery's
-- _refinery_schema_history for SQL-level migrations). Each row is
-- one wiki migration that has been run against this data dir.
CREATE TABLE wiki_migrations (
name TEXT PRIMARY KEY,
applied_at INTEGER NOT NULL -- unix microseconds, UTC
);
+17
View File
@@ -692,6 +692,23 @@ pub fn rename_project(
}
}
/// Record a successfully-applied wiki-structure migration.
///
/// Uses `INSERT OR IGNORE` so re-running the same name is a no-op
/// (idempotent by design — the runner already skips known names, but
/// this guards against any concurrent writes).
pub fn insert_wiki_migration(
conn: &mut Connection,
name: &str,
applied_at: i64,
) -> StoreResult<()> {
conn.execute(
"INSERT OR IGNORE INTO wiki_migrations (name, applied_at) VALUES (?1, ?2)",
params![name, applied_at],
)?;
Ok(())
}
/// Delete a project and all its data inside one transaction.
///
/// Execution order:
+18
View File
@@ -1431,6 +1431,24 @@ impl ReaderPool {
})
.await
}
/// Return all migration names recorded in the `wiki_migrations` table.
///
/// Used by the wiki migration runner to determine which migrations have
/// already been applied to this data directory.
///
/// # Errors
/// Propagates any SQL or pool error.
pub async fn wiki_migration_names(&self) -> StoreResult<Vec<String>> {
self.with_conn(|conn| {
let mut stmt = conn.prepare("SELECT name FROM wiki_migrations ORDER BY name")?;
let names = stmt
.query_map([], |row| row.get::<_, String>(0))?
.collect::<Result<Vec<_>, _>>()?;
Ok(names)
})
.await
}
}
fn page_meta_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<StoreResult<PageMeta>> {
+35
View File
@@ -109,6 +109,13 @@ pub(crate) enum WriteCmd {
new_name: String,
reply: oneshot::Sender<StoreResult<()>>,
},
/// Record a successfully-applied wiki-structure migration.
InsertWikiMigration {
name: String,
/// Unix microseconds UTC.
applied_at: i64,
reply: oneshot::Sender<StoreResult<()>>,
},
Shutdown,
}
@@ -373,6 +380,26 @@ impl WriterHandle {
rx.await.map_err(|_| StoreError::WriterClosed)?
}
/// Record a wiki-structure migration as successfully applied.
///
/// Called by the wiki migration runner immediately after [`WikiMigration::up`]
/// returns `Ok`. `applied_at` is unix microseconds UTC. If the name is
/// already present the call is a no-op (idempotent insert-or-ignore).
///
/// # Errors
/// Returns [`StoreError::WriterClosed`] if the actor has shut down, or
/// propagates the SQL error.
pub async fn insert_wiki_migration(&self, name: String, applied_at: i64) -> StoreResult<()> {
let (tx, rx) = oneshot::channel();
self.send(WriteCmd::InsertWikiMigration {
name,
applied_at,
reply: tx,
})
.await?;
rx.await.map_err(|_| StoreError::WriterClosed)?
}
/// Retro-fit sessions and their observations to per-cwd projects and
/// graveyard any mash-up pages. The `plan` slice contains
/// `(session_id, new_project_id)` pairs. Everything runs in one
@@ -566,6 +593,14 @@ fn worker_loop(mut conn: Connection, mut rx: mpsc::Receiver<WriteCmd>) {
let result = ops::rename_project(&mut conn, &workspace_id, &project_id, &new_name);
send_or_warn(reply, result, "rename_project");
}
WriteCmd::InsertWikiMigration {
name,
applied_at,
reply,
} => {
let result = ops::insert_wiki_migration(&mut conn, &name, applied_at);
send_or_warn(reply, result, "insert_wiki_migration");
}
}
}
tracing::debug!("writer thread exiting cleanly");
+1
View File
@@ -24,6 +24,7 @@ tokio.workspace = true
notify.workspace = true
notify-debouncer-full.workspace = true
git2.workspace = true
async-trait.workspace = true
[dev-dependencies]
+2
View File
@@ -9,11 +9,13 @@ mod atomic;
mod error;
mod git;
mod markdown;
pub mod migrations;
mod watcher;
mod wiki;
pub use error::{WikiError, WikiResult};
pub use git::{COMMIT_AUTHOR_EMAIL, COMMIT_AUTHOR_NAME, GitAdapter};
pub use markdown::{Markdown, derive_title, emit, parse};
pub use migrations::run_pending as run_wiki_migrations;
pub use watcher::{DEBOUNCE_WINDOW, RECONCILE_INTERVAL, WatcherHandle};
pub use wiki::{Wiki, WritePageRequest};
+102
View File
@@ -0,0 +1,102 @@
//! Wiki-structure migration framework.
//!
//! SQL schema migrations are handled by `refinery` and run before this
//! layer is invoked. Wiki migrations handle *filesystem* changes — file
//! moves, path rewrites, directory renames — that are required when the
//! on-disk wiki layout changes between versions.
//!
//! ## Adding a new migration
//!
//! 1. Create a struct in a new submodule (e.g. `crates/ai-memory-wiki/src/migrations/m2026_05_24_per_project_layout.rs`).
//! 2. Implement [`WikiMigration`] for it.
//! 3. Add an instance to the `vec![]` in [`registry`]. **Always append at the
//! end — never reorder or remove entries.**
//! 4. Add a test in your module that exercises the migration against a tmp
//! wiki directory.
//!
//! ## Naming convention
//!
//! Migration names use `YYYY_MM_DDTHH_MM_<descriptive_snake_case>`. The
//! timestamp is UTC and chosen at authoring time, not applied time. Using a
//! timestamp rather than a sequence number avoids merge conflicts when two
//! contributors add migrations in parallel.
//!
//! ## Idempotency
//!
//! Every implementation of [`WikiMigration::up`] must be idempotent: if the
//! work is already done (files already in the target layout, target dir
//! already absent, etc.), the migration exits with `Ok(())` without touching
//! anything. The runner marks it applied on first success and never re-runs
//! it, but a correct implementation guards against the invariant anyway.
//!
//! ## What NOT to do
//!
//! - **No destructive deletes without a graveyard step.** Move the file to
//! `<wiki_root>/_graveyard/<timestamp>/<original_path>` before deleting so
//! data is recoverable for at least one release cycle.
//! - **No LLM calls.** Migrations run on every server start; they must be
//! fast and free.
//! - **No SQL outside the writer actor.** Use [`WriterHandle`] methods so all
//! writes go through the single-writer channel (invariant #2).
mod runner;
use std::path::Path;
use ai_memory_store::WriterHandle;
pub use runner::run_pending;
use crate::error::WikiResult;
/// A single wiki-structure migration.
///
/// Implementors describe one idempotent filesystem (and optional SQL)
/// transformation. The runner calls [`up`](WikiMigration::up) exactly once
/// per data directory, tracking completion in the `wiki_migrations` table.
#[async_trait::async_trait]
pub trait WikiMigration: Send + Sync {
/// Unique, sortable name. Convention: `YYYY_MM_DDTHH_MM_<descriptive_snake>`.
///
/// This value is stored in the `wiki_migrations` table as the primary key.
/// Once chosen and shipped it must never change.
fn name(&self) -> &'static str;
/// One-line description shown in server logs.
fn description(&self) -> &'static str;
/// Apply the migration.
///
/// The `writer` handle is provided so any SQL updates that accompany the
/// file moves go through the single-writer actor (no race with hooks).
/// `wiki_root` is the on-disk root of the wiki directory
/// (`<data_dir>/wiki/`).
///
/// Implementations **must** be idempotent: if the work has already been
/// done they return `Ok(())` immediately.
///
/// # Errors
///
/// Any error returned here causes the server to bail at startup. The
/// migration row is NOT inserted into `wiki_migrations`, so the next start
/// will retry.
async fn up(&self, writer: &WriterHandle, wiki_root: &Path) -> WikiResult<()>;
}
/// The canonical migration registry.
///
/// Migrations run sequentially in the order they appear. **Always append at
/// the end; never reorder or remove entries.** The runner uses this order
/// together with the `wiki_migrations` table to determine which entries are
/// still pending.
///
/// v1 ships with no pending migrations: the per-project UUID-namespaced
/// layout is the native format from day one and requires no transformation
/// of pre-existing data.
#[must_use]
pub fn registry() -> Vec<Box<dyn WikiMigration>> {
// Keep this empty until a structural change actually needs migrating
// existing installs. Appending to an empty vec is a zero-diff PR for
// future contributors.
vec![]
}
@@ -0,0 +1,273 @@
//! Runner that applies pending wiki-structure migrations in order.
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
use ai_memory_store::{ReaderPool, WriterHandle};
use tracing::{error, info};
use super::WikiMigration;
use crate::error::{WikiError, WikiResult};
/// Read the set of already-applied migration names from the database.
async fn applied_names(reader: &ReaderPool) -> WikiResult<Vec<String>> {
reader
.wiki_migration_names()
.await
.map_err(WikiError::Store)
}
/// Record one migration as successfully applied.
async fn mark_applied(writer: &WriterHandle, name: &str) -> WikiResult<()> {
let micros = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_micros() as i64)
.unwrap_or(0);
writer
.insert_wiki_migration(name.to_owned(), micros)
.await
.map_err(WikiError::Store)
}
/// Run all pending wiki-structure migrations.
///
/// Reads the `wiki_migrations` table to determine which names from
/// `registry` have not yet been applied, then runs each pending
/// migration in registration order.
///
/// A migration that returns [`Err`] causes this function to bail
/// immediately. The failed migration's name is **not** inserted into
/// `wiki_migrations`, so the next server start will retry it.
///
/// # Errors
///
/// Returns the error from the first failing migration, or any database
/// error that prevents reading/writing the `wiki_migrations` table.
pub async fn run_pending(
writer: &WriterHandle,
reader: &ReaderPool,
wiki_root: &Path,
registry: &[Box<dyn WikiMigration>],
) -> WikiResult<()> {
if registry.is_empty() {
return Ok(());
}
let applied = applied_names(reader).await?;
for migration in registry {
let name = migration.name();
if applied.iter().any(|n| n == name) {
continue;
}
info!(
migration = name,
description = migration.description(),
"running wiki migration"
);
if let Err(e) = migration.up(writer, wiki_root).await {
error!(
migration = name,
error = %e,
"wiki migration failed — server cannot start until this is resolved"
);
return Err(e);
}
mark_applied(writer, name).await?;
info!(migration = name, "applied wiki migration");
}
Ok(())
}
#[cfg(test)]
mod tests {
use std::sync::{Arc, Mutex};
use tempfile::TempDir;
use super::*;
use crate::error::WikiResult;
use crate::migrations::WikiMigration;
// ── helpers ──────────────────────────────────────────────────────────────
fn open_store(dir: &TempDir) -> ai_memory_store::Store {
ai_memory_store::Store::open(dir.path()).expect("store open")
}
// ── synthetic migrations ──────────────────────────────────────────────────
struct CountingMigration {
name: &'static str,
run_count: Arc<Mutex<u32>>,
}
#[async_trait::async_trait]
impl WikiMigration for CountingMigration {
fn name(&self) -> &'static str {
self.name
}
fn description(&self) -> &'static str {
"counting migration for tests"
}
async fn up(&self, _writer: &WriterHandle, _wiki_root: &Path) -> WikiResult<()> {
*self.run_count.lock().unwrap() += 1;
Ok(())
}
}
struct FailingMigration;
#[async_trait::async_trait]
impl WikiMigration for FailingMigration {
fn name(&self) -> &'static str {
"2026_01_01T00_00_failing"
}
fn description(&self) -> &'static str {
"always fails"
}
async fn up(&self, _writer: &WriterHandle, _wiki_root: &Path) -> WikiResult<()> {
Err(WikiError::Io(std::io::Error::other(
"synthetic migration failure",
)))
}
}
// ── tests ─────────────────────────────────────────────────────────────────
/// Empty registry → no-op, no rows inserted.
#[tokio::test]
async fn empty_registry_is_noop() {
let dir = TempDir::new().unwrap();
let store = open_store(&dir);
let wiki_root = dir.path().join("wiki");
std::fs::create_dir_all(&wiki_root).unwrap();
let result = run_pending(&store.writer, &store.reader, &wiki_root, &[]).await;
assert!(result.is_ok());
let names = store.reader.wiki_migration_names().await.unwrap();
assert!(names.is_empty());
}
/// Migration runs once; second call is a no-op.
#[tokio::test]
async fn runs_once_then_skips() {
let dir = TempDir::new().unwrap();
let store = open_store(&dir);
let wiki_root = dir.path().join("wiki");
std::fs::create_dir_all(&wiki_root).unwrap();
let count = Arc::new(Mutex::new(0u32));
let migration: Box<dyn WikiMigration> = Box::new(CountingMigration {
name: "2026_01_01T00_00_counting",
run_count: count.clone(),
});
let registry: Vec<Box<dyn WikiMigration>> = vec![migration];
run_pending(&store.writer, &store.reader, &wiki_root, &registry)
.await
.unwrap();
assert_eq!(*count.lock().unwrap(), 1, "ran once");
// Rebuild registry (can't clone Box<dyn>) and run again.
let migration2: Box<dyn WikiMigration> = Box::new(CountingMigration {
name: "2026_01_01T00_00_counting",
run_count: count.clone(),
});
let registry2: Vec<Box<dyn WikiMigration>> = vec![migration2];
run_pending(&store.writer, &store.reader, &wiki_root, &registry2)
.await
.unwrap();
assert_eq!(*count.lock().unwrap(), 1, "still one — not re-run");
}
/// Failing migration → Err returned, row NOT inserted, next call retries.
#[tokio::test]
async fn failing_migration_not_marked_applied() {
let dir = TempDir::new().unwrap();
let store = open_store(&dir);
let wiki_root = dir.path().join("wiki");
std::fs::create_dir_all(&wiki_root).unwrap();
let registry: Vec<Box<dyn WikiMigration>> = vec![Box::new(FailingMigration)];
let result = run_pending(&store.writer, &store.reader, &wiki_root, &registry).await;
assert!(result.is_err(), "must propagate the error");
let names = store.reader.wiki_migration_names().await.unwrap();
assert!(
names.is_empty(),
"failed migration must not be marked applied"
);
// Re-run → still errors, still not applied.
let registry2: Vec<Box<dyn WikiMigration>> = vec![Box::new(FailingMigration)];
let result2 = run_pending(&store.writer, &store.reader, &wiki_root, &registry2).await;
assert!(result2.is_err());
let names2 = store.reader.wiki_migration_names().await.unwrap();
assert!(names2.is_empty());
}
/// Two migrations in registry → both run in order.
#[tokio::test]
async fn two_migrations_run_in_order() {
let dir = TempDir::new().unwrap();
let store = open_store(&dir);
let wiki_root = dir.path().join("wiki");
std::fs::create_dir_all(&wiki_root).unwrap();
let order: Arc<Mutex<Vec<&'static str>>> = Arc::new(Mutex::new(vec![]));
struct OrderMigration {
name: &'static str,
log: Arc<Mutex<Vec<&'static str>>>,
}
#[async_trait::async_trait]
impl WikiMigration for OrderMigration {
fn name(&self) -> &'static str {
self.name
}
fn description(&self) -> &'static str {
"order test"
}
async fn up(&self, _writer: &WriterHandle, _wiki_root: &Path) -> WikiResult<()> {
self.log.lock().unwrap().push(self.name);
Ok(())
}
}
let registry: Vec<Box<dyn WikiMigration>> = vec![
Box::new(OrderMigration {
name: "2026_01_01T00_00_first",
log: order.clone(),
}),
Box::new(OrderMigration {
name: "2026_01_01T00_01_second",
log: order.clone(),
}),
];
run_pending(&store.writer, &store.reader, &wiki_root, &registry)
.await
.unwrap();
let ran = order.lock().unwrap().clone();
assert_eq!(
ran,
vec!["2026_01_01T00_00_first", "2026_01_01T00_01_second"],
"must run in registration order"
);
// Both names persisted.
let mut names = store.reader.wiki_migration_names().await.unwrap();
names.sort();
assert_eq!(names.len(), 2);
}
}
+171
View File
@@ -0,0 +1,171 @@
# Wiki-structure migrations
SQL schema migrations are handled automatically by `refinery` at server
startup. This document covers the parallel mechanism for *filesystem-level*
changes to the wiki directory.
## When to write a wiki migration
Write a wiki migration any time a new version of ai-memory requires an
on-disk wiki directory that was created by an older version to be
restructured. Examples that require a migration:
- The path scheme changes (e.g. `<wiki_root>/<page>.md` → `<wiki_root>/<workspace>/<project>/<page>.md`).
- A directory is renamed, split, or merged.
- Every page of a certain kind gets a new required frontmatter field added.
- Log rotation changes the filename pattern for `log.md` backups.
Do **not** write a migration for changes that are purely additive and
backward-compatible (e.g. a new optional frontmatter field that defaults to
`null`).
## How to write a wiki migration
### 1. Create the migration file
Add a new file in `crates/ai-memory-wiki/src/migrations/`. Use the naming
convention:
```
m<YYYY>_<MM>_<DD>_<HH><MM>_<descriptive_name>.rs
```
For example: `m2026_06_01_1200_rename_logs_dir.rs`.
### 2. Implement the `WikiMigration` trait
```rust
use std::path::Path;
use ai_memory_store::WriterHandle;
use crate::error::WikiResult;
use crate::migrations::WikiMigration;
pub struct RenameLogs2026;
#[async_trait::async_trait]
impl WikiMigration for RenameLogs2026 {
fn name(&self) -> &'static str {
// Must be unique and sortable. Choose once and never change.
"2026_06_01T12_00_rename_logs_dir"
}
fn description(&self) -> &'static str {
"rename _logs/ to _log/ for consistency with log.md"
}
async fn up(&self, _writer: &WriterHandle, wiki_root: &Path) -> WikiResult<()> {
let old = wiki_root.join("_logs");
let new = wiki_root.join("_log");
// Idempotency: if the work is already done, return Ok immediately.
if !old.exists() {
return Ok(());
}
std::fs::rename(&old, &new)?;
Ok(())
}
}
```
### 3. Register it
Open `crates/ai-memory-wiki/src/migrations/mod.rs` and append to the
`registry()` function:
```rust
pub fn registry() -> Vec<Box<dyn WikiMigration>> {
vec![
// existing entries...
Box::new(super::m2026_06_01_1200_rename_logs_dir::RenameLogs2026),
]
}
```
Also add `mod m2026_06_01_1200_rename_logs_dir;` near the top of `mod.rs`.
**Never reorder or remove entries.** The runner uses the registration order
together with the `wiki_migrations` table.
### 4. Add a unit test
Every migration module must include a `#[cfg(test)]` block that:
- Exercises the migration against a `tempfile::TempDir`.
- Verifies the pre-condition (old layout present), post-condition (new layout
present, old absent), and idempotency (running twice is a no-op).
```rust
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
use ai_memory_store::Store;
#[tokio::test]
async fn renames_logs_dir() {
let dir = TempDir::new().unwrap();
let store = Store::open(dir.path()).unwrap();
let wiki_root = dir.path().join("wiki");
std::fs::create_dir_all(&wiki_root).unwrap();
// Pre-condition.
std::fs::create_dir(wiki_root.join("_logs")).unwrap();
let m = RenameLogs2026;
m.up(&store.writer, &wiki_root).await.unwrap();
assert!(wiki_root.join("_log").exists());
assert!(!wiki_root.join("_logs").exists());
// Idempotent: running again does not error.
m.up(&store.writer, &wiki_root).await.unwrap();
}
}
```
## How NOT to write a migration
### No destructive deletes without a graveyard step
If a migration removes files (not just moves them), copy or move them to
`<wiki_root>/_graveyard/<migration_name>/<original_path>` first. This lets
operators recover accidentally-deleted data for at least one release cycle.
```rust
// BAD — data is gone forever on upgrade.
std::fs::remove_dir_all(wiki_root.join("_tmp"))?;
// GOOD — data lands in the graveyard, recoverable.
let graveyard = wiki_root.join("_graveyard").join(self.name());
std::fs::create_dir_all(&graveyard)?;
std::fs::rename(wiki_root.join("_tmp"), graveyard.join("_tmp"))?;
```
### No LLM calls
Migrations run on every server start. They must be fast and free. Any
transformation that requires a language model belongs in a one-time CLI
command or a consolidation job, not a migration.
### No direct SQL outside the writer actor
If a migration needs to update the SQLite index alongside the file moves, use
`WriterHandle` methods. Never open a second `Connection`; never call
`ops::*` directly from a migration. This upholds invariant #2 (single-writer
actor) from `CLAUDE.md`.
## Tracking
Applied migrations are recorded in the `wiki_migrations` SQLite table:
```sql
SELECT name, datetime(applied_at / 1000000, 'unixepoch') AS applied
FROM wiki_migrations
ORDER BY name;
```
The table is created by `V06__wiki_migrations.sql` (a `refinery` migration
that runs before any wiki migrations). The server bails with a clear error
message if a migration fails; re-starting the server retries the failed
migration automatically.
+3 -3
View File
@@ -1,8 +1,8 @@
[package]
name = "ai-memory-eval"
version = "0.1.0"
edition = "2024"
rust-version = "1.95"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license = "MIT OR Apache-2.0"
publish = false