agskillmanager 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agskillmanager-1.0.0.dist-info/METADATA +168 -0
- agskillmanager-1.0.0.dist-info/RECORD +24 -0
- agskillmanager-1.0.0.dist-info/WHEEL +4 -0
- agskillmanager-1.0.0.dist-info/licenses/LICENSE +202 -0
- skill_manager/__init__.py +137 -0
- skill_manager/_optional.py +18 -0
- skill_manager/_xai.py +48 -0
- skill_manager/api.py +965 -0
- skill_manager/config.py +33 -0
- skill_manager/discovery.py +345 -0
- skill_manager/errors.py +83 -0
- skill_manager/generation.py +83 -0
- skill_manager/integrations/__init__.py +1 -0
- skill_manager/integrations/langchain.py +130 -0
- skill_manager/models.py +307 -0
- skill_manager/observability.py +67 -0
- skill_manager/otel.py +107 -0
- skill_manager/postgres.py +318 -0
- skill_manager/py.typed +0 -0
- skill_manager/registry.py +195 -0
- skill_manager/resolution.py +187 -0
- skill_manager/sources.py +175 -0
- skill_manager/testing.py +362 -0
- skill_manager/validation.py +95 -0
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
"""PostgreSQL Skill source and registry.
|
|
2
|
+
|
|
3
|
+
Install with ``pip install 'agskillmanager[postgres]'``. Both classes take a
|
|
4
|
+
psycopg ``AsyncConnectionPool``; the caller opens it, and the class closes it in
|
|
5
|
+
``aclose`` only when constructed with ``owns_pool=True``. Call `ensure_schema`
|
|
6
|
+
once to create the table and indexes. On Windows, run psycopg's async API on a
|
|
7
|
+
selector event loop: ``asyncio.run(main(), loop_factory=asyncio.SelectorEventLoop)``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from contextlib import AbstractAsyncContextManager, asynccontextmanager
|
|
14
|
+
from hashlib import sha256
|
|
15
|
+
from typing import TYPE_CHECKING, Any
|
|
16
|
+
|
|
17
|
+
from skill_manager._optional import optional_import
|
|
18
|
+
from skill_manager.errors import SkillConflictError, SkillNotFoundError, SkillSourceError
|
|
19
|
+
from skill_manager.models import Skill, SkillLifecycleState
|
|
20
|
+
from skill_manager.registry import SkillRegistry, check_write, latest
|
|
21
|
+
from skill_manager.sources import SkillSource, validate_source_id
|
|
22
|
+
from skill_manager.validation import dump_skill_yaml
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from collections.abc import AsyncGenerator, Iterable, Mapping
|
|
26
|
+
|
|
27
|
+
from psycopg import AsyncConnection
|
|
28
|
+
from psycopg_pool import AsyncConnectionPool
|
|
29
|
+
|
|
30
|
+
_psycopg = optional_import("psycopg", "postgres")
|
|
31
|
+
_sql = optional_import("psycopg.sql", "postgres")
|
|
32
|
+
_Jsonb = optional_import("psycopg.types.json", "postgres").Jsonb
|
|
33
|
+
_TABLE = re.compile(r"[A-Za-z_][A-Za-z0-9_]{0,62}")
|
|
34
|
+
_ACTIVE = SkillLifecycleState.ACTIVE.value
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _table(name: str) -> Any:
|
|
38
|
+
if not _TABLE.fullmatch(name):
|
|
39
|
+
raise ValueError("table must be a plain SQL identifier of at most 63 characters")
|
|
40
|
+
return _sql.Identifier(name)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@asynccontextmanager
|
|
44
|
+
async def _borrow(
|
|
45
|
+
pool: AsyncConnectionPool[Any], message: str
|
|
46
|
+
) -> AsyncGenerator[AsyncConnection[Any]]:
|
|
47
|
+
"""Borrow a pooled connection, translating database errors into Skill errors."""
|
|
48
|
+
try:
|
|
49
|
+
async with pool.connection() as connection:
|
|
50
|
+
yield connection
|
|
51
|
+
except _psycopg.errors.UniqueViolation as error:
|
|
52
|
+
raise SkillConflictError("a concurrent write created a second active revision") from error
|
|
53
|
+
except _psycopg.Error as error:
|
|
54
|
+
raise SkillSourceError(message) from error
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class _PoolOwner:
|
|
58
|
+
def __init__(self, pool: AsyncConnectionPool[Any], *, owns_pool: bool) -> None:
|
|
59
|
+
self._pool = pool
|
|
60
|
+
self._owns_pool = owns_pool
|
|
61
|
+
|
|
62
|
+
async def aclose(self) -> None:
|
|
63
|
+
"""Close the pool when this object owns it."""
|
|
64
|
+
if self._owns_pool:
|
|
65
|
+
await self._pool.close()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class PostgresSkillSource(_PoolOwner, SkillSource):
|
|
69
|
+
"""Store Skill YAML documents in one PostgreSQL table, keyed by source and Skill ID.
|
|
70
|
+
|
|
71
|
+
Several sources can share a table under different ``source_id`` values.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
pool: Open psycopg async connection pool.
|
|
75
|
+
source_id: Stable identifier for this source.
|
|
76
|
+
table: Table name; created by `ensure_schema`.
|
|
77
|
+
owns_pool: Close ``pool`` in `aclose`.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
def __init__(
|
|
81
|
+
self,
|
|
82
|
+
pool: AsyncConnectionPool[Any],
|
|
83
|
+
*,
|
|
84
|
+
source_id: str = "postgres",
|
|
85
|
+
table: str = "skill_manager_documents",
|
|
86
|
+
owns_pool: bool = False,
|
|
87
|
+
) -> None:
|
|
88
|
+
super().__init__(pool, owns_pool=owns_pool)
|
|
89
|
+
self._source_id = validate_source_id(source_id)
|
|
90
|
+
self._table = _table(table)
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def source_id(self) -> str:
|
|
94
|
+
"""Return the configured source identifier."""
|
|
95
|
+
return self._source_id
|
|
96
|
+
|
|
97
|
+
async def ensure_schema(self) -> None:
|
|
98
|
+
"""Create the document table if it does not exist."""
|
|
99
|
+
await self._execute(
|
|
100
|
+
"CREATE TABLE IF NOT EXISTS {table} ("
|
|
101
|
+
" source_id text NOT NULL,"
|
|
102
|
+
" skill_id text NOT NULL,"
|
|
103
|
+
" document text NOT NULL,"
|
|
104
|
+
" updated_at timestamptz NOT NULL DEFAULT now(),"
|
|
105
|
+
" PRIMARY KEY (source_id, skill_id))",
|
|
106
|
+
(),
|
|
107
|
+
"cannot create the Skill document table",
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
async def load(self) -> Mapping[str, str]:
|
|
111
|
+
"""Return every document of this source, keyed by Skill ID."""
|
|
112
|
+
rows = await self._execute(
|
|
113
|
+
"SELECT skill_id, document FROM {table} WHERE source_id = %s ORDER BY skill_id",
|
|
114
|
+
(self._source_id,),
|
|
115
|
+
f"cannot load PostgreSQL Skill source {self._source_id!r}",
|
|
116
|
+
fetch=True,
|
|
117
|
+
)
|
|
118
|
+
return dict(rows)
|
|
119
|
+
|
|
120
|
+
async def save(self, skill: Skill) -> None:
|
|
121
|
+
"""Insert or replace the document of ``skill``."""
|
|
122
|
+
await self._execute(
|
|
123
|
+
"INSERT INTO {table} (source_id, skill_id, document) VALUES (%s, %s, %s)"
|
|
124
|
+
" ON CONFLICT (source_id, skill_id)"
|
|
125
|
+
" DO UPDATE SET document = EXCLUDED.document, updated_at = now()",
|
|
126
|
+
(self._source_id, skill.skill_id, dump_skill_yaml(skill)),
|
|
127
|
+
f"cannot persist Skill {skill.skill_id!r}",
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
async def delete(self, skill_id: str) -> None:
|
|
131
|
+
"""Delete the document of ``skill_id``, if present."""
|
|
132
|
+
await self._execute(
|
|
133
|
+
"DELETE FROM {table} WHERE source_id = %s AND skill_id = %s",
|
|
134
|
+
(self._source_id, skill_id),
|
|
135
|
+
f"cannot delete Skill {skill_id!r}",
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
async def _execute(
|
|
139
|
+
self, query: str, params: tuple[Any, ...], message: str, *, fetch: bool = False
|
|
140
|
+
) -> list[Any]:
|
|
141
|
+
async with _borrow(self._pool, message) as connection:
|
|
142
|
+
cursor = await connection.execute(_sql.SQL(query).format(table=self._table), params)
|
|
143
|
+
return await cursor.fetchall() if fetch else []
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class PostgresSkillRegistry(_PoolOwner, SkillRegistry):
|
|
147
|
+
"""Shared Skill catalog in PostgreSQL, safe across processes.
|
|
148
|
+
|
|
149
|
+
A partial unique index guarantees one ``ACTIVE`` revision per name and
|
|
150
|
+
version, even under concurrent writers; a lost race raises
|
|
151
|
+
`SkillConflictError`.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
pool: Open psycopg async connection pool.
|
|
155
|
+
table: Table name; created by `ensure_schema`.
|
|
156
|
+
owns_pool: Close ``pool`` in `aclose`.
|
|
157
|
+
"""
|
|
158
|
+
|
|
159
|
+
def __init__(
|
|
160
|
+
self,
|
|
161
|
+
pool: AsyncConnectionPool[Any],
|
|
162
|
+
*,
|
|
163
|
+
table: str = "skill_manager_skills",
|
|
164
|
+
owns_pool: bool = False,
|
|
165
|
+
) -> None:
|
|
166
|
+
super().__init__(pool, owns_pool=owns_pool)
|
|
167
|
+
self._table = _table(table)
|
|
168
|
+
self._active_index = _sql.Identifier(
|
|
169
|
+
f"sm_active_{sha256(table.encode('ascii')).hexdigest()[:32]}"
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
async def ensure_schema(self) -> None:
|
|
173
|
+
"""Create the catalog table and its indexes if they do not exist."""
|
|
174
|
+
statements = (
|
|
175
|
+
"CREATE TABLE IF NOT EXISTS {table} ("
|
|
176
|
+
" skill_id text PRIMARY KEY,"
|
|
177
|
+
" name text NOT NULL,"
|
|
178
|
+
" version text NOT NULL,"
|
|
179
|
+
" revision integer NOT NULL,"
|
|
180
|
+
" state text NOT NULL,"
|
|
181
|
+
" document jsonb NOT NULL)",
|
|
182
|
+
"CREATE UNIQUE INDEX IF NOT EXISTS {active} ON {table} (name, version)"
|
|
183
|
+
" WHERE state = 'ACTIVE'",
|
|
184
|
+
)
|
|
185
|
+
async with self._connection() as connection:
|
|
186
|
+
for statement in statements:
|
|
187
|
+
await connection.execute(self._sql(statement))
|
|
188
|
+
|
|
189
|
+
async def put(self, skill: Skill) -> None:
|
|
190
|
+
"""Insert or idempotently rewrite one revision."""
|
|
191
|
+
async with self._connection() as connection, connection.transaction():
|
|
192
|
+
await self._check(connection, skill, lock=True)
|
|
193
|
+
await connection.execute(
|
|
194
|
+
self._sql(
|
|
195
|
+
"INSERT INTO {table} (skill_id, name, version, revision, state, document)"
|
|
196
|
+
" VALUES (%s, %s, %s, %s, %s, %s) ON CONFLICT (skill_id) DO UPDATE SET"
|
|
197
|
+
" revision = EXCLUDED.revision, state = EXCLUDED.state,"
|
|
198
|
+
" document = EXCLUDED.document"
|
|
199
|
+
),
|
|
200
|
+
(
|
|
201
|
+
skill.skill_id,
|
|
202
|
+
skill.metadata.name,
|
|
203
|
+
skill.metadata.version,
|
|
204
|
+
skill.revision,
|
|
205
|
+
skill.lifecycle.state.value,
|
|
206
|
+
_Jsonb(skill.model_dump(mode="json")),
|
|
207
|
+
),
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
async def check_put(self, skill: Skill) -> None:
|
|
211
|
+
"""Validate a write without applying it."""
|
|
212
|
+
async with self._connection() as connection:
|
|
213
|
+
await self._check(connection, skill, lock=False)
|
|
214
|
+
|
|
215
|
+
async def get(self, skill_id: str) -> Skill:
|
|
216
|
+
"""Return a Skill by ID."""
|
|
217
|
+
rows = await self._fetch("SELECT document FROM {table} WHERE skill_id = %s", (skill_id,))
|
|
218
|
+
if not rows:
|
|
219
|
+
raise SkillNotFoundError(f"Skill {skill_id!r} was not found")
|
|
220
|
+
return Skill.model_validate(rows[0][0])
|
|
221
|
+
|
|
222
|
+
async def by_name(self, name: str, version: str | None = None) -> Skill:
|
|
223
|
+
"""Return the active Skill with ``name`` and optional exact ``version``."""
|
|
224
|
+
rows = await self._fetch(
|
|
225
|
+
"SELECT document FROM {table} WHERE name = %s AND state = %s"
|
|
226
|
+
" AND (%s::text IS NULL OR version = %s)",
|
|
227
|
+
(name, _ACTIVE, version, version),
|
|
228
|
+
)
|
|
229
|
+
if not rows:
|
|
230
|
+
raise SkillNotFoundError(f"Skill {name!r} version {version!r} was not found")
|
|
231
|
+
return latest(Skill.model_validate(row[0]) for row in rows)
|
|
232
|
+
|
|
233
|
+
async def all(
|
|
234
|
+
self, *, states: Iterable[SkillLifecycleState] | None = None
|
|
235
|
+
) -> tuple[Skill, ...]:
|
|
236
|
+
"""Return every Skill, optionally filtered by lifecycle state."""
|
|
237
|
+
if states is None:
|
|
238
|
+
rows = await self._fetch("SELECT document FROM {table} ORDER BY skill_id", ())
|
|
239
|
+
else:
|
|
240
|
+
rows = await self._fetch(
|
|
241
|
+
"SELECT document FROM {table} WHERE state = ANY(%s) ORDER BY skill_id",
|
|
242
|
+
([state.value for state in states],),
|
|
243
|
+
)
|
|
244
|
+
return tuple(Skill.model_validate(row[0]) for row in rows)
|
|
245
|
+
|
|
246
|
+
async def remove(self, skill_id: str) -> Skill:
|
|
247
|
+
"""Remove and return one revision."""
|
|
248
|
+
rows = await self._fetch(
|
|
249
|
+
"DELETE FROM {table} WHERE skill_id = %s RETURNING document", (skill_id,)
|
|
250
|
+
)
|
|
251
|
+
if not rows:
|
|
252
|
+
raise SkillNotFoundError(f"Skill {skill_id!r} was not found")
|
|
253
|
+
return Skill.model_validate(rows[0][0])
|
|
254
|
+
|
|
255
|
+
async def replace_lifecycle(self, skill: Skill) -> None:
|
|
256
|
+
"""Overwrite an existing revision after a lifecycle transition."""
|
|
257
|
+
async with self._connection() as connection, connection.transaction():
|
|
258
|
+
cursor = await connection.execute(
|
|
259
|
+
self._sql("SELECT 1 FROM {table} WHERE skill_id = %s FOR UPDATE"),
|
|
260
|
+
(skill.skill_id,),
|
|
261
|
+
)
|
|
262
|
+
if await cursor.fetchone() is None:
|
|
263
|
+
raise SkillNotFoundError(f"Skill {skill.skill_id!r} was not found")
|
|
264
|
+
if skill.lifecycle.state is SkillLifecycleState.ACTIVE and await self._peers(
|
|
265
|
+
connection, skill
|
|
266
|
+
):
|
|
267
|
+
raise SkillConflictError(
|
|
268
|
+
f"another active revision already exists for {skill.metadata.name!r}"
|
|
269
|
+
)
|
|
270
|
+
await connection.execute(
|
|
271
|
+
self._sql(
|
|
272
|
+
"UPDATE {table} SET revision = %s, state = %s, document = %s"
|
|
273
|
+
" WHERE skill_id = %s"
|
|
274
|
+
),
|
|
275
|
+
(
|
|
276
|
+
skill.revision,
|
|
277
|
+
skill.lifecycle.state.value,
|
|
278
|
+
_Jsonb(skill.model_dump(mode="json")),
|
|
279
|
+
skill.skill_id,
|
|
280
|
+
),
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
async def _check(self, connection: AsyncConnection[Any], skill: Skill, *, lock: bool) -> None:
|
|
284
|
+
cursor = await connection.execute(
|
|
285
|
+
self._sql(
|
|
286
|
+
"SELECT document FROM {table} WHERE skill_id = %s FOR UPDATE"
|
|
287
|
+
if lock
|
|
288
|
+
else "SELECT document FROM {table} WHERE skill_id = %s"
|
|
289
|
+
),
|
|
290
|
+
(skill.skill_id,),
|
|
291
|
+
)
|
|
292
|
+
row = await cursor.fetchone()
|
|
293
|
+
existing = Skill.model_validate(row[0]) if row is not None else None
|
|
294
|
+
check_write(existing, skill, await self._peers(connection, skill))
|
|
295
|
+
|
|
296
|
+
async def _peers(self, connection: AsyncConnection[Any], skill: Skill) -> list[str]:
|
|
297
|
+
cursor = await connection.execute(
|
|
298
|
+
self._sql(
|
|
299
|
+
"SELECT skill_id FROM {table} WHERE name = %s AND version = %s"
|
|
300
|
+
" AND state = %s AND skill_id <> %s"
|
|
301
|
+
),
|
|
302
|
+
(skill.metadata.name, skill.metadata.version, _ACTIVE, skill.skill_id),
|
|
303
|
+
)
|
|
304
|
+
return [row[0] for row in await cursor.fetchall()]
|
|
305
|
+
|
|
306
|
+
async def _fetch(self, query: str, params: tuple[Any, ...]) -> list[Any]:
|
|
307
|
+
async with self._connection() as connection:
|
|
308
|
+
cursor = await connection.execute(self._sql(query), params)
|
|
309
|
+
return await cursor.fetchall()
|
|
310
|
+
|
|
311
|
+
def _sql(self, query: str) -> Any:
|
|
312
|
+
return _sql.SQL(query).format(table=self._table, active=self._active_index)
|
|
313
|
+
|
|
314
|
+
def _connection(self) -> AbstractAsyncContextManager[AsyncConnection[Any]]:
|
|
315
|
+
return _borrow(self._pool, "PostgreSQL Skill registry operation failed")
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
__all__ = ["PostgresSkillRegistry", "PostgresSkillSource"]
|
skill_manager/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Skill catalog contract and its in-memory implementation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from abc import ABC, abstractmethod
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from packaging.version import Version
|
|
10
|
+
|
|
11
|
+
from skill_manager.errors import SkillConflictError, SkillNotFoundError
|
|
12
|
+
from skill_manager.models import Skill, SkillLifecycleState
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from collections.abc import Iterable
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SkillRegistry(ABC):
|
|
19
|
+
"""Validated Skill catalog used by `SkillManager`.
|
|
20
|
+
|
|
21
|
+
Implementations may keep the catalog in process (`InMemorySkillRegistry`) or
|
|
22
|
+
in a shared database (`skill_manager.postgres.PostgresSkillRegistry`). Every
|
|
23
|
+
implementation must keep these promises, which
|
|
24
|
+
`skill_manager.testing.SkillRegistryContract` verifies:
|
|
25
|
+
|
|
26
|
+
- A revision is immutable: writing a different Skill with the same ID and
|
|
27
|
+
revision raises `SkillConflictError`, and writing an older revision is a
|
|
28
|
+
stale update that raises `SkillConflictError`.
|
|
29
|
+
- At most one ``ACTIVE`` revision exists per name and version.
|
|
30
|
+
- `get` and `remove` raise `SkillNotFoundError` for unknown IDs.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
@abstractmethod
|
|
34
|
+
async def put(self, skill: Skill) -> None:
|
|
35
|
+
"""Insert a Skill revision, or idempotently rewrite an identical one."""
|
|
36
|
+
|
|
37
|
+
@abstractmethod
|
|
38
|
+
async def check_put(self, skill: Skill) -> None:
|
|
39
|
+
"""Raise the error `put` would raise, without writing anything."""
|
|
40
|
+
|
|
41
|
+
@abstractmethod
|
|
42
|
+
async def get(self, skill_id: str) -> Skill:
|
|
43
|
+
"""Return a Skill by immutable ID."""
|
|
44
|
+
|
|
45
|
+
@abstractmethod
|
|
46
|
+
async def by_name(self, name: str, version: str | None = None) -> Skill:
|
|
47
|
+
"""Return the active Skill with ``name``.
|
|
48
|
+
|
|
49
|
+
Without ``version``, return the highest PEP 440 version.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
@abstractmethod
|
|
53
|
+
async def all(
|
|
54
|
+
self, *, states: Iterable[SkillLifecycleState] | None = None
|
|
55
|
+
) -> tuple[Skill, ...]:
|
|
56
|
+
"""Return every Skill, optionally only those in ``states``."""
|
|
57
|
+
|
|
58
|
+
@abstractmethod
|
|
59
|
+
async def remove(self, skill_id: str) -> Skill:
|
|
60
|
+
"""Remove and return one Skill revision."""
|
|
61
|
+
|
|
62
|
+
@abstractmethod
|
|
63
|
+
async def replace_lifecycle(self, skill: Skill) -> None:
|
|
64
|
+
"""Overwrite an existing Skill after a lifecycle transition."""
|
|
65
|
+
|
|
66
|
+
async def aclose(self) -> None: # noqa: B027 - optional hook with a no-op default
|
|
67
|
+
"""Release resources; the default does nothing."""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def check_write(existing: Skill | None, skill: Skill, active_peers: Iterable[str]) -> None:
|
|
71
|
+
"""Apply the registry write rules shared by every implementation.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
existing: The stored revision with the same ID, if any.
|
|
75
|
+
skill: The revision being written.
|
|
76
|
+
active_peers: IDs of other ``ACTIVE`` revisions with the same name and version.
|
|
77
|
+
|
|
78
|
+
Raises:
|
|
79
|
+
SkillConflictError: If the write is stale, overwrites an immutable
|
|
80
|
+
revision, or creates a second active revision.
|
|
81
|
+
"""
|
|
82
|
+
if skill.lifecycle.state is SkillLifecycleState.ACTIVE and any(
|
|
83
|
+
peer != skill.skill_id for peer in active_peers
|
|
84
|
+
):
|
|
85
|
+
raise SkillConflictError(
|
|
86
|
+
f"multiple active revisions for Skill {skill.metadata.name!r} "
|
|
87
|
+
f"version {skill.metadata.version!r}"
|
|
88
|
+
)
|
|
89
|
+
if existing is None:
|
|
90
|
+
return
|
|
91
|
+
if existing.revision > skill.revision:
|
|
92
|
+
raise SkillConflictError(f"stale update for Skill {skill.skill_id!r}")
|
|
93
|
+
if existing.revision == skill.revision and existing != skill:
|
|
94
|
+
raise SkillConflictError(
|
|
95
|
+
f"immutable Skill revision {skill.skill_id!r} cannot be overwritten"
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def latest(skills: Iterable[Skill]) -> Skill:
|
|
100
|
+
"""Return the Skill with the highest PEP 440 version, breaking ties by ID."""
|
|
101
|
+
return max(skills, key=lambda item: (Version(item.metadata.version), item.skill_id))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class InMemorySkillRegistry(SkillRegistry):
|
|
105
|
+
"""Concurrency-safe, process-local Skill registry."""
|
|
106
|
+
|
|
107
|
+
def __init__(self) -> None:
|
|
108
|
+
self._by_id: dict[str, Skill] = {}
|
|
109
|
+
self._by_key: dict[tuple[str, str], set[str]] = {}
|
|
110
|
+
self._lock = asyncio.Lock()
|
|
111
|
+
|
|
112
|
+
def _active_peers(self, skill: Skill) -> set[str]:
|
|
113
|
+
key = (skill.metadata.name, skill.metadata.version)
|
|
114
|
+
return {
|
|
115
|
+
skill_id
|
|
116
|
+
for skill_id in self._by_key.get(key, ())
|
|
117
|
+
if self._by_id[skill_id].lifecycle.state is SkillLifecycleState.ACTIVE
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async def put(self, skill: Skill) -> None:
|
|
121
|
+
"""Insert or idempotently rewrite one revision."""
|
|
122
|
+
async with self._lock:
|
|
123
|
+
check_write(self._by_id.get(skill.skill_id), skill, self._active_peers(skill))
|
|
124
|
+
self._by_id[skill.skill_id] = skill
|
|
125
|
+
self._by_key.setdefault((skill.metadata.name, skill.metadata.version), set()).add(
|
|
126
|
+
skill.skill_id
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
async def check_put(self, skill: Skill) -> None:
|
|
130
|
+
"""Validate a write without applying it."""
|
|
131
|
+
async with self._lock:
|
|
132
|
+
check_write(self._by_id.get(skill.skill_id), skill, self._active_peers(skill))
|
|
133
|
+
|
|
134
|
+
async def get(self, skill_id: str) -> Skill:
|
|
135
|
+
"""Return a Skill by ID."""
|
|
136
|
+
async with self._lock:
|
|
137
|
+
try:
|
|
138
|
+
return self._by_id[skill_id]
|
|
139
|
+
except KeyError:
|
|
140
|
+
raise SkillNotFoundError(f"Skill {skill_id!r} was not found") from None
|
|
141
|
+
|
|
142
|
+
async def by_name(self, name: str, version: str | None = None) -> Skill:
|
|
143
|
+
"""Return the active Skill with ``name`` and optional exact ``version``."""
|
|
144
|
+
async with self._lock:
|
|
145
|
+
matches = [
|
|
146
|
+
skill
|
|
147
|
+
for (skill_name, skill_version), ids in self._by_key.items()
|
|
148
|
+
if skill_name == name and version in {None, skill_version}
|
|
149
|
+
for skill_id in ids
|
|
150
|
+
if (skill := self._by_id[skill_id]).lifecycle.state is SkillLifecycleState.ACTIVE
|
|
151
|
+
]
|
|
152
|
+
if not matches:
|
|
153
|
+
raise SkillNotFoundError(f"Skill {name!r} version {version!r} was not found")
|
|
154
|
+
return latest(matches)
|
|
155
|
+
|
|
156
|
+
async def all(
|
|
157
|
+
self, *, states: Iterable[SkillLifecycleState] | None = None
|
|
158
|
+
) -> tuple[Skill, ...]:
|
|
159
|
+
"""Return every Skill, optionally filtered by lifecycle state."""
|
|
160
|
+
async with self._lock:
|
|
161
|
+
values = tuple(self._by_id.values())
|
|
162
|
+
if states is None:
|
|
163
|
+
return values
|
|
164
|
+
wanted = frozenset(states)
|
|
165
|
+
return tuple(skill for skill in values if skill.lifecycle.state in wanted)
|
|
166
|
+
|
|
167
|
+
async def remove(self, skill_id: str) -> Skill:
|
|
168
|
+
"""Remove and return one revision."""
|
|
169
|
+
async with self._lock:
|
|
170
|
+
try:
|
|
171
|
+
skill = self._by_id.pop(skill_id)
|
|
172
|
+
except KeyError:
|
|
173
|
+
raise SkillNotFoundError(f"Skill {skill_id!r} was not found") from None
|
|
174
|
+
key = (skill.metadata.name, skill.metadata.version)
|
|
175
|
+
ids = self._by_key[key]
|
|
176
|
+
ids.discard(skill_id)
|
|
177
|
+
if not ids:
|
|
178
|
+
del self._by_key[key]
|
|
179
|
+
return skill
|
|
180
|
+
|
|
181
|
+
async def replace_lifecycle(self, skill: Skill) -> None:
|
|
182
|
+
"""Overwrite an existing revision after a lifecycle transition."""
|
|
183
|
+
async with self._lock:
|
|
184
|
+
if skill.skill_id not in self._by_id:
|
|
185
|
+
raise SkillNotFoundError(f"Skill {skill.skill_id!r} was not found")
|
|
186
|
+
if skill.lifecycle.state is SkillLifecycleState.ACTIVE and (
|
|
187
|
+
self._active_peers(skill) - {skill.skill_id}
|
|
188
|
+
):
|
|
189
|
+
raise SkillConflictError(
|
|
190
|
+
f"another active revision already exists for {skill.metadata.name!r}"
|
|
191
|
+
)
|
|
192
|
+
self._by_id[skill.skill_id] = skill
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
__all__ = ["InMemorySkillRegistry", "SkillRegistry", "check_write", "latest"]
|