agentic-community / agentic-community/agentic-primitives-gateway

Schema migration framework for the spec store

オープン
#6 コメント 0 件 リアクション 0 件 担当者 0 名 GitHub で見る
enhancement
主要言語
Python
スター
17
フォーク
5
平均マージ
1日 25分
マージ済み PR(30日)
7

説明

# Schema migration framework for the spec store

## Summary

The `migrate_from_legacy` one-shot path was removed when pre-versioned data
stopped being in the wild. Going forward any change to the on-disk (file) or
on-Redis layout of `_StoreState` / `AgentVersion` / `TeamVersion` / the
identity index / the Redis key scheme will need a proper migration, and
today there's no framework to put one in.

This issue tracks adding that framework before the next schema change lands.

## Baseline

The shape committed as of commit `7a0720f` ("Remove migrate_from_legacy;
treat current schema as v0") is **schema version 0**. Any future change
is a v0→v1 migration, v1→v2, etc.

Affected surfaces:

- `_StoreState` (`base_store.py`) — the in-memory shape every backend
persists.
- `AgentVersion` / `TeamVersion` (`models/agents.py` / `models/teams.py`)
— the embedded records.
- The file layout (`agents.json` / `teams.json`) — a JSON document with
`versions` / `identities` / `proposals`.
- The Redis key layout (`gateway:agents:*`, `gateway:teams:*`).

Anything else (API response shapes, audit event fields, config) is out of
scope for this issue — those should evolve through normal additive
changes.

## Why not just keep adding `migrate_*` methods?

- Every shape change would stack another method on the mixins
(`migrate_from_v0`, `migrate_from_v1`, ...), polluting the persistence
layer with per-revision logic.
- No record of *which* version the current data is at — we'd have to
re-run every migration on every startup and rely on them being
idempotent.
- Multi-replica: lifespan-level migrations race across replicas. Only
idempotent or explicitly-locked migrations are safe.
- Test story: the ad-hoc `migrate_from_legacy` we had worked for v0→v1
because it's idempotent via UUIDv5. Nothing else is that forgiving.

## Proposed design

### 1. Stamp state with a schema version

`_StoreState` gains an integer `schema_version` field. Existing stores
load as version 0.

```python
class _StoreState:
versions: dict[str, dict]
identities: dict[str, dict]
proposals: list[str]
schema_version: int = 0 # NEW
```

The file store serializes it; the Redis store stores it alongside the
existing hashes (e.g. `gateway:agents:schema_version`).

### 2. Numbered migration registry

A `src/agentic_primitives_gateway/agents/migrations/` subpackage with
modules named `_0001_*.py`, `_0002_*.py`, etc. Each exports a single
`MIGRATION` object:

```python
# migrations/_0001_example.py
from agentic_primitives_gateway.agents.migrations import Migration

MIGRATION = Migration(
target_version=1,
entity="agent", # "agent" | "team" | "both"
description="Rename spec.primitives.agents.tools to delegates",
apply=_apply,
)

def _apply(state: _StoreState) -> None:
for version in state.versions.values():
spec = version["spec"]
if "primitives" in spec and "agents" in spec["primitives"]:
tools = spec["primitives"]["agents"].pop("tools", None)
if tools is not None:
spec["primitives"]["agents"]["delegates"] = tools
```

### 3. Run-migrations method on `SpecStore`

```python
async def run_migrations(self) -> None:
state = await self._load_state()
registry = self._load_migration_registry()
pending = [m for m in registry
if m.entity in (self._entity_label, "both")
and m.target_version > state.schema_version]
if not pending:
return
pending.sort(key=lambda m: m.target_version)
for m in pending:
logger.info("Applying migration %s → v%d", m.description, m.target_version)
m.apply(state)
state.schema_version = m.target_version
await self._save_state(state)
```

### 4. Lifespan wiring

`main.py` gains:

```python
await agent_store.run_migrations()
await team_store.run_migrations()
```

after construction, before seed.

### 5. Multi-replica safety

File store: single-process, no lock needed.

Redis store: wrap `run_migrations` in a `SET NX EX 300` lock on
`gateway:agents:migration_lock` so only one replica applies. Others
spin until the stamp bumps, or fail fast with "schema version N+1
expected, got N" if the lock holder dies.

### 6. Forward-only

Rollback migrations are out of scope. If we need to revert, ship a
forward migration that undoes the offending change. Downgrade requires
redeploying the older binary + replaying from a backup.

## Success criteria

- [ ] `_StoreState.schema_version` exists and serializes to both backends.
- [ ] `migrations/` subpackage with an obvious naming + loading scheme.
- [ ] `SpecStore.run_migrations()` implemented, invoked from lifespan.
- [ ] At least one toy migration (`_0001_*.py`) to validate the end-to-end
path, plus an integration test that starts a store at v0, runs
migrations, asserts stamp bumps + data transforms.
- [ ] Redis lock wraps the Redis mixin's migration run.
- [ ] Docs updated (`docs/concepts/agent-versioning.md`) explaining when
operators need to think about migrations (spoiler: almost never —
they just run automatically).

## Out of scope

- Rollback migrations.
- Online migrations that run while traffic is served (the lifespan
model assumes a brief downtime window at boot, which matches every
other lifespan-scoped setup today).
- Cross-entity migrations (e.g. "rename this agent name everywhere it
appears in a team's workers list"). These are harder because they
need both stores locked simultaneously. Defer until actually needed.
- GUI for migration history / manual re-run. Logs + the stamp are
enough for the operator use case.

## References

- Commit `7a0720f` — removes `migrate_from_legacy`, establishes v0 baseline.
- `src/agentic_primitives_gateway/agents/base_store.py` — where `_StoreState`
lives today.
- `src/agentic_primitives_gateway/agents/file_store.py` /
`src/agentic_primitives_gateway/agents/redis_store.py` — the two backends
that need the stamp + lock.

コントリビューションガイド

コントリビューションガイドを開く

評価

この issue はまだ評価されていません。

新しい issue をメールで受け取る

初心者向けの GitHub issue を短くまとめたダイジェスト。