feat(skills): open skill-management-console change + local skill store

Opens the skill-management-console openspec change (cloud/local skill split
with local override) with proposal, design (D1-D11), four delta specs, and
tasks. Implements the agent-side persistent local skill store
(storage/local_skills.py): authored local skills + cloud-skill overrides in
a physically separate SQLite file, with fork-on-revocation. 10 tests pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 07:28:42 +08:00
co-authored by Claude Opus 4.6
parent e5a12f9b74
commit dd03abbbb0
11 changed files with 1031 additions and 0 deletions
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-14
@@ -0,0 +1,3 @@
# skill-management-console
Cloud Console surface for managing Skills: read-only view of synced skills + subscription/sync status, and local authoring/editing of skills (hybrid). Amends the skill-catalog capability to permit locally-authored skills alongside read-only synced ones.
@@ -0,0 +1,129 @@
## Context
`skill-catalog-subscription` (archived) shipped the **consumption** side of Skills: a read-only synced catalog (`storage/skill_catalog.py`), MCP discovery tools (`api/skill_catalog_mcp.py`), and a transport-replaceable sync client (`api/skill_sync.py`) that assumed an external "Subscription Platform" as the skill source. Three gaps remain:
1. **No source.** The external Subscription Platform was never built; `SkillSyncRunner` is not wired into any running app, so `tasks/skills.sqlite3` is empty and `list_skills`/`search_skills`/`get_skill` return nothing at runtime.
2. **No management surface.** There is no human-facing way to author or manage skills, and no local-authoring path.
3. **No local agency.** An agent cannot create skills for itself or adapt a cloud skill to its own context.
This change closes all three by introducing a **cloud-side management surface** (this project's Cloud API becomes the skill source, replacing the assumed external platform) and a **local-authoring + override** path on the agent. Skills split into two pools — **cloud-origin** (human-managed via Cloud Console) and **local** (agent-managed via MCP tools) — that never write into each other's store, with a local **override** layer that lets an agent shadow a cloud skill.
This builds on `apex-agent-mvp` (MCP server / agent runtime), `database-llm-provider-management` (the Cloud DB + admin-surface pattern mirrored here), and the archived `skill-catalog-subscription` / `skill-authoring` capabilities.
## Goals / Non-Goals
**Goals:**
- Cloud Console + Cloud API for humans to author/manage cloud-origin skills and assign them to hosts.
- Agent MCP authoring tools for the LLM to create/edit/delete its own local skills, and to override a cloud skill locally.
- A unified read surface (`list_skills`/`search_skills`/`get_skill`) that merges cloud-synced and local skills with an `origin` discriminator.
- Local override of cloud skills (shadow semantics), with a defined lifecycle against sync updates and entitlement revocation.
- Make the Skill capability usable end-to-end without an external platform: wire `SkillSyncRunner` into the running host agent, repointing sync at this project's Cloud API.
- Per-host visibility of an agent's local skills in the Console (read-only inventory).
**Non-Goals:**
- The external Subscription Platform (its billing/marketplace/authoring workflow). This project's Cloud API is the source instead.
- LLM-driven automatic skill generation.
- A human UI for authoring an agent's *local* skills (local authoring is LLM-via-MCP only).
- Push/webhook-triggered sync (deferred; pull is baseline — see D3).
- Batch/server-side flow execution (unchanged from `skill-mcp-tools` D5).
## Decisions
### D1: Cloud skill store reuses the Cloud platform DB (SQLAlchemy + Alembic)
Cloud-origin skills are administrator-managed configuration/content data, directly analogous to LLM Provider profiles. They live in the existing Cloud platform database via new SQLAlchemy models + an Alembic migration, reusing the established repository / admin-scope / CSRF / audit machinery (mirroring `cloud/llm_providers.py` + migration `0007`). No separate store.
### D2: Per-host entitlement
A cloud skill is visible to an agent only if it is explicitly entitled to that agent's host. New cloud tables: `cloud_skills` (the skill rows) and `cloud_skill_entitlements` (a `skill_id × host_id` mapping). The Console manages both skill CRUD and entitlement assignment. An agent syncs only its own host's entitled set. There is no global visibility and no tenant/group abstraction in this change.
### D3: Incremental pull-only sync
The agent periodically pulls an incremental delta of its host's entitled skills from the Cloud API. The Cloud maintains a monotonic **per-host `entitlement_version`** that bumps on any change to that host's entitled set (skill added/removed/edited, entitlement granted/revoked). The agent sends `since_version`; the Cloud returns upserted skills, `removed_ids`, and the new `latest_version`. Full-replace is used on first sync (`since_version` absent) or when the Cloud cannot serve an incremental delta (version too old). The existing `SubscriptionClient` Protocol / `SyncDelta` shape already supports this; only the concrete HTTP client and endpoint change. **Push/webhook is deferred** — correctness never depends on it.
### D4: Local skills persist to a dedicated SQLite file
The agent's local skills (authored + overrides) persist in a new file `tasks/local_skills.sqlite3`, physically separate from the synced `tasks/skills.sqlite3` and from `tasks/tasks.sqlite3`. This preserves the archived "no cross-writes / physical separation" boundary from `skill-authoring` / `skill-catalog-subscription` D6-D7. The existing in-memory `skills_learning.SkillStore` (used by local-synthesis/embedding subsystems) is **not** the home for managed local skills; a new persistent local-skill store is introduced (see Layering). The existing `Skill`/`FlowTemplateSkill`/`KnowledgeSkill` domain models are reused verbatim; the `SkillMetadata.source` field discriminates local skills (`source = "local"`) from synced ones.
### D5: Origin discriminator is a simple read-surface field
The merged read surface reports each skill with `origin: "cloud" | "local"`, derived from which store it came from (not stored redundantly). No entitlement/billing/cloud-management internals are exposed to the LLM. Overrides add a `locally_overridden: true` flag (see D11). This is the only provenance information the LLM receives.
### D6: No authoring guard
The authoring MCP tools (`create_skill`, `update_skill`, `delete_skill`) are always registered on the agent's MCP server; there is no enable/disable config gate. The blast radius is already limited to the agent's own local store (cloud skills cannot be mutated directly — see D10), so an operator switch is not warranted for this change.
### D7: Agent reports its local skill inventory up to the Cloud (read-only)
The agent periodically reports a read-only summary of its local skills (authored + active overrides) to the Cloud API, so the Console can display per-host local skills. This is a new agent→Cloud reverse channel (a host-scoped `POST` carrying local skill metadata, no secrets/content beyond what is already non-sensitive). The Console renders it per host. Reporting is best-effort: a failed report does not affect local operation.
### D8: Override always shadows
A local override for a cloud skill id takes precedence at read time for as long as it exists. When the underlying cloud skill updates via sync, the override **continues to shadow** the new cloud version — the agent does not see the cloud update until the override is removed. This is simple and predictable; staleness is the operator/agent's responsibility. (Version-staleness flagging and auto-invalidation were considered and rejected for MVP complexity.)
### D9: Override forks into a standalone local skill on entitlement revocation
If a cloud skill is removed from the agent's entitled set (sync delivers it in `removed_ids`) **and** a local override exists for it, the override is **promoted to a standalone local skill**: it gets a local id, `origin` becomes `"local"`, and it survives independently of the (now-absent) cloud skill. The agent keeps its customized version rather than losing it. If no override exists, revocation simply removes the cloud skill from the local view as before.
### D10: Authoring tools dispatch by origin (no dedicated override tool)
- `create_skill(...)` → creates a new **local** skill (fresh local id).
- `update_skill(skill_id, ...)`:
- `skill_id` is a **local** skill → edits it in the local store.
- `skill_id` is a **cloud** skill → creates/updates the local **override** for that cloud id (never writes to the cloud store or the synced store).
- `delete_skill(skill_id)`:
- `skill_id` is a **local** skill → deletes it.
- `skill_id` is a **cloud** skill with an active override → removes the override (the cloud skill resurfaces at its next read).
- `skill_id` is a **cloud** skill with no override → semantic error (nothing local to delete; the agent cannot revoke a cloud entitlement from here).
Dispatch is by origin lookup, not by an id-prefix convention. A dedicated `override_skill` tool was considered and rejected in favour of fewer tools with origin-based dispatch.
### D11: Origin reporting for overrides
- Cloud skill, no override: `origin = "cloud"`, no flag.
- Cloud skill with an active override: `origin = "cloud"`, `locally_overridden = true` (provenance is still cloud; the flag says it is shadowed).
- After a revocation fork (D9): `origin = "local"` (it has detached from the cloud identity).
- Authored local skill: `origin = "local"`.
## Architecture / Layering
Per `docs/CONSTITUTION.md` (HTTP/MCP enter at `api`; no LLM/HTTP/MCP in `core`/`driver`/`device`/`tools`; dependency direction `core → driver/device → tools → perception → storage → runtime → api`):
| Concern | Location (new/extended) | Layer | Key deps |
|---|---|---|---|
| Cloud skill domain + service | `packages/cloud-platform/cloud/skills.py` (new) | cloud service | dataclasses, validation, `cloud.provider_secrets` only if secrets ever needed (none here) |
| Cloud SQLAlchemy models | `packages/cloud-platform/cloud/db_models.py` (extend) | cloud storage | SQLAlchemy |
| Cloud repository methods | `packages/cloud-platform/cloud/sql_repository.py` (extend) | cloud storage | SQLAlchemy, `cloud.skills` |
| Cloud Alembic migration | `cloud/migrations/versions/0010_skill_management.py` (new) | cloud storage | Alembic |
| Cloud admin REST (skill CRUD + entitlement) | `cloud/internal_api/api.py` + new admin router (extend) | cloud api | FastAPI, `cloud.skills`, auth/CSRF/scope |
| Cloud host-scoped sync endpoint + inventory-report endpoint | `cloud/internal_api/api.py` (extend) | cloud api | FastAPI, host-scoped bearer auth (same as planner-decision) |
| Persistent local-skill store (authored + overrides) | `storage/local_skills.py` (new) | storage | `sqlite3`, `skills_learning.models` — zero HTTP/MCP |
| Unified read/merge + origin + override resolution | `api/skill_catalog_view.py` (new) | api | `storage.skill_catalog`, `storage.local_skills` |
| Skill MCP tools (read merge + authoring dispatch) | `api/skill_catalog_mcp.py` (extend) | api | `api.skill_catalog_view`, FastMCP |
| Sync concrete client repoint + runner wiring | `api/skill_sync.py` (extend) + host-agent app bootstrap | api / runtime | `httpx`, `storage.skill_catalog` |
| Console view + API client + types | `cloud-console/src/views/SkillsView.vue`, `cloud-console/src/api`-equiv, `cloud-console/src/types.ts` (extend) | frontend | Vue, existing admin-auth/CSRF client |
`storage/skill_catalog.py` (the synced store) is **unchanged in its read-only-except-sync contract**; it continues to receive only sync writes. Local authored skills and overrides live exclusively in `storage/local_skills.py``tasks/local_skills.sqlite3`. The merge happens in `api/skill_catalog_view.py`, the only module that reads both.
## Override Model (detail)
- Overrides are stored in the local store keyed by the cloud skill id they shadow.
- Read merge order, per id:
1. If a local override exists for the id → return the override payload, `origin="cloud"`, `locally_overridden=true`.
2. Else if the synced store has the id (and it is visible) → return it, `origin="cloud"`.
3. Else if the local store has the id as a standalone local skill → return it, `origin="local"`.
4. Else → not found (no existence leak between stores).
- `update_skill(cloud_id)` upserts the override; `delete_skill(cloud_id)` removes it (cloud skill resurfaces on next read).
- Fork-on-revocation (D9): when sync removes a cloud id that has an override, the override row is rewritten to a standalone local skill (new local id, `source="local"`, content preserved), and the old cloud-id key is dropped. Subsequent reads see it as `origin="local"`.
## Migration Plan
Additive across existing packages; no new top-level package.
- **Cloud**: new Alembic migration `0010_skill_management.py` adds `cloud_skills`, `cloud_skill_entitlements`, and a per-host `cloud_skill_sync_state` (host_id, last_version, updated_at) table. No existing table is altered. New admin scope `skills:admin` (mirrors `llm-providers:admin`).
- **Agent**: new `tasks/local_skills.sqlite3` created lazily by `storage/local_skills.py`. `storage/skill_catalog.py` is untouched. `api/skill_catalog_view.py` + extended `api/skill_catalog_mcp.py` add the merge + authoring surface. `api/skill_sync.py`'s concrete client is repointed at the Cloud API; `SkillSyncRunner` is constructed and started in the host-agent app bootstrap (behind the existing cloud-transport configuration).
- **Rollback**: drop the cloud migration's tables; delete `tasks/local_skills.sqlite3`; remove the new `storage/local_skills.py`, `api/skill_catalog_view.py`, the authoring tool registrations, and the Console view. The synced store and original read tools revert to their archived behaviour (empty until an external source exists).
## Risks / Trade-offs
- **[Risk]** Sync staleness under overrides (D8): an override masks cloud updates indefinitely. → **Mitigation**: explicit, documented; the Console's per-host inventory (D7) surfaces active overrides so an operator can see what is shadowed.
- **[Risk]** Fork-on-revocation (D9) can let an agent keep a customized copy of a skill the operator tried to revoke. → **Mitigation**: the fork contains only what the agent already had (no new content); revocation still removes the cloud entitlement. Accepted trade-off: local agency over strict central control. The Console inventory makes forks visible.
- **[Risk]** Per-host entitlement + incremental versioning adds cloud-side bookkeeping (the `entitlement_version` must bump correctly on every relevant change). → **Mitigation**: centralize the bump in the service layer (`cloud/skills.py`) behind the repository port; cover with repository/service tests.
- **[Trade-off]** No authoring guard (D6) means an agent can fill its local store freely. Accepted: blast radius is local-only and the store is per-agent.
- **[Trade-off]** Reusing `SkillMetadata.source` for origin keeps the domain model unchanged but means origin is inferred at the merge layer rather than stored as a first-class field. Accepted: avoids model divergence between synced and local skills.
- **[Risk]** Inventory reporting (D7) is a new agent→Cloud channel that could leak local-skill metadata upstream. → **Mitigation**: report metadata only (id/name/kind/origin/overridden-cloud-id/version/timestamps), never secrets; the agent already trusts the Cloud API as its sync source.
## Open Questions
- Exact Cloud sync endpoint path and whether inventory report rides on the same host-scoped auth as the planner-decision endpoint (likely yes).
- Whether flow-template tool-reference validation (already applied to synced skills) should also apply to locally-authored/override skills at author time vs read time (lean: validate at `get_skill`/`resolve_flow_template` time uniformly, warn — not block — at author time).
- Console UX for entitlement assignment (per-skill host picker vs per-host skill picker) — a UI detail, resolved during Console implementation.
@@ -0,0 +1,40 @@
## Why
The `skill-catalog-subscription` change shipped the **consumption** side of Skills (local read-only catalog, MCP discovery tools, sync-client contract), but assumed an external "Subscription Platform" (统一订阅平台) as the skill source — a system that does not exist. With no source feeding the catalog and no sync runner wired into any running app, `list_skills`/`search_skills`/`get_skill` are registered but return empty at runtime, and an operator has no way to author or manage skills. We need both a **cloud-side management surface** (so humans can author the skills agents consume) and a **local-authoring path** (so an agent can create skills for itself) before the Skill capability is usable end-to-end — without waiting on an external platform.
## What Changes
Skills are split into **two pools with separate management entry points that never cross each other**:
- **Cloud-origin skills** — authored and managed by humans through a new **Cloud Console management surface** (UI + Cloud API REST + a cloud-side skill store). This cloud store becomes the source agents sync from, replacing the previously-assumed external Subscription Platform.
- **Local skills** — authored and managed by the agent's LLM through new **authoring MCP tools**, living in a dedicated persistent local store (a new file `tasks/local_skills.sqlite3`, physically separate from the synced catalog store and from the in-memory local-synthesis store). The agent can read both pools but writes only to its own local pool: it cannot mutate a cloud skill directly. It may, however, create a **local override** that shadows a cloud skill (see below).
Specifically:
- Add a **Cloud Console management surface** for cloud-origin skills: list/view, create, edit, and delete cloud skills, plus visibility into which agents/tenants are entitled to which skills. Backed by a new cloud-side skill store and Cloud API REST endpoints.
- Add **authoring MCP tools** (`create_skill` / `update_skill` / `delete_skill`) on the agent's MCP server that **dispatch by origin**: editing/deleting local skills, and creating/updating/removing **local overrides** for cloud skills (never writing to the cloud or synced store). Authoring errors are surfaced as semantic MCP errors consistent with the existing read tools.
- Add **local override** of cloud skills: a local override shadows a cloud skill at read time, wins against sync updates until removed, and forks into a standalone local skill if the cloud entitlement is revoked.
- Make the existing **MCP read tools** (`list_skills` / `search_skills` / `get_skill`) present a **unified catalog** by merging cloud-synced skills (read-only, after overrides) and local skills, so a single discovery surface serves both pools. Each skill carries an `origin` discriminator (`cloud` | `local`) and overrides are flagged.
- Repoint the **sync source**: the agent's skill-subscription-sync now pulls **incremental per-host deltas** from this project's own Cloud API skill store, rather than an assumed external platform, and the sync runner is wired into the running host agent. The sync capability's transport-replaceable contract is preserved — only the concrete upstream changes.
- Have the agent **report its local-skill inventory** up to the Cloud (read-only) so the Console can show per-host local skills.
- Persist **local skills** across agent restarts in the dedicated local store.
## Capabilities
### New Capabilities
- `skill-management-console`: Cloud-side management of cloud-origin skills — the Cloud Console UI, the Cloud API REST endpoints for skill CRUD and per-host entitlement, the versioned host-scoped sync endpoint agents pull from, the agent local-skill inventory readback, and the cloud-side skill store. This is the human-facing authoring and management entry point and the authoritative source for cloud skills.
- `local-skill-management`: Agent-side management of the agent's own local skills — a persistent local skill store (separate from the synced catalog store), local authoring semantics (create/update/delete), the local override model (shadow a cloud skill; fork to a standalone local skill on entitlement revocation), and the origin-discriminated read-merge over synced + local skills. The synced catalog store's read-only-except-sync invariant and the physical separation between local and synced stores remain intact.
### Modified Capabilities
- `skill-mcp-tools`: (a) the existing read tools present a unified catalog merged from cloud-synced and local skills with an `origin` discriminator (delegating to `local-skill-management`'s merge); (b) new `create_skill` / `update_skill` / `delete_skill` authoring tools are added that dispatch by origin — editing/deleting local skills, and creating/updating/removing local overrides for cloud skills (never writing to the cloud or synced store).
- `skill-subscription-sync`: the concrete sync upstream is this project's Cloud API skill store (incremental per-host pull), not an external Subscription Platform; the runner is wired into the running host agent; and the agent reports its local-skill inventory up to the Cloud. The transport-replaceable interface contract is preserved.
(`skill-catalog`, which governs the synced store, is unchanged: local skills live in the separate store introduced by `local-skill-management`, and the merge happens above it. No delta is needed for `skill-catalog`.)
## Impact
- **New code (cloud-side)**: a cloud-side skill store + schema/migration, Cloud API REST endpoints + handlers for cloud-skill CRUD and entitlement readback, audit/CSRF/scope guards consistent with the existing `llm-providers:admin`-style admin surface, and a Cloud Console view + API client (`cloud-console/src/views`, API layer).
- **New code (agent-side)**: authoring MCP tool registrations in `api/skill_catalog_mcp.py`; a merge layer so the read tools query both `storage/skill_catalog.py` (synced) and `skills_learning/store.py` (local); persistence for `skills_learning/store.py` (currently in-memory).
- **Existing code**: `storage/skill_catalog.py` query surface, `api/skill_sync.py`'s concrete client (repointed to Cloud API), and the `skills_learning` store.
- **Capabilities**: new `skill-management-console` and `local-skill-management`; modified `skill-mcp-tools` and `skill-subscription-sync`. `skill-catalog` (synced store) is unchanged.
- **Open design questions (design.md)**: exact shape of the cloud-side skill store and its entitlement model; whether local-skill persistence reuses a SQLite file alongside `tasks/skills.sqlite3` or extends `skills_learning/store.py`; how origin is discriminated and surfaced to the LLM without leaking cloud-management internals; whether the cloud console also offers a read-only view of an individual agent's local skills (likely out of scope).
- **Still out of scope**: billing/marketplace, LLM-driven automatic skill generation, and a human UI for authoring an agent's *local* skills (local authoring is LLM-via-MCP only).
@@ -0,0 +1,67 @@
## ADDED Requirements
### Requirement: Agent-local skills persist in a dedicated store
The agent SHALL persist its locally-managed skills (authored skills and overrides) in a local store backed by a SQLite file physically separate from the synced catalog store and from task metadata, so that local authoring survives agent restarts and never shares storage with synced skills. The synced catalog store's read-only-except-sync write contract and the "no cross-writes between local and synced stores" boundary SHALL remain intact.
#### Scenario: Locally-authored skill survives restart
- **WHEN** the agent creates a local skill and is later restarted
- **THEN** the local skill is still present and readable after restart, without any re-sync
#### Scenario: Local and synced stores stay physically separate
- **WHEN** the agent authors a local skill and separately syncs cloud skills
- **THEN** the local skill is written only to the local store and the synced skills only to the synced store; neither store accepts the other's writes
### Requirement: Agent can author, edit, and delete its own local skills
The agent SHALL be able to create new local skills, edit existing local skills, and delete local skills, operating exclusively on the local store. These operations SHALL NOT touch the synced store or any cloud skill.
#### Scenario: Create a new local skill
- **WHEN** the agent creates a skill with no existing id
- **THEN** a new local skill is persisted with a fresh local id and `origin = "local"`
#### Scenario: Edit and delete a local skill
- **WHEN** the agent updates, then deletes, a local skill by its local id
- **THEN** the update is persisted to the local store, and the delete removes it from the local store; the synced store is unchanged
### Requirement: Agent can locally override a cloud skill
The agent SHALL be able to create a local override keyed by a cloud skill's id. While an override exists, the read surface returns the override's content for that id (the cloud version is shadowed). Creating or updating an override SHALL NOT modify the cloud skill or the synced store.
#### Scenario: Override shadows the cloud skill at read time
- **WHEN** the agent has created an override for a cloud skill id and the read surface is queried for that id
- **THEN** the override's content is returned, marked `origin = "cloud"` and `locally_overridden = true`
#### Scenario: Removing an override re-exposes the cloud skill
- **WHEN** the agent deletes the override for a cloud skill id
- **THEN** subsequent reads for that id return the cloud skill's current synced content, with no override flag
### Requirement: Overrides shadow sync updates until removed
When the underlying cloud skill for an overridden id is updated via sync, the override SHALL continue to shadow the new cloud version; the agent SHALL NOT see the cloud update until the override is removed. (Staleness is explicit and operator/agent-visible, not auto-detected.)
#### Scenario: Cloud skill updates under an active override
- **WHEN** a synced cloud skill whose id has an active local override is updated to a new version
- **THEN** the read surface continues to return the override's content, not the new cloud version
### Requirement: An override forks into a local skill on entitlement revocation
If a cloud skill is removed from the agent's entitled set (sync reports it as removed) and a local override exists for it, the override SHALL be promoted to a standalone local skill: it gains a local id, its `origin` becomes `"local"`, and its content is preserved. If no override exists, revocation simply removes the cloud skill from the local view.
#### Scenario: Revocation with an active override forks it
- **WHEN** sync removes a cloud skill id that has an active local override
- **THEN** the override becomes a standalone local skill with a new local id and `origin = "local"`, and remains readable
#### Scenario: Revocation without an override just removes visibility
- **WHEN** sync removes a cloud skill id that has no local override
- **THEN** that id is no longer visible on the read surface
### Requirement: Unified read surface merges synced and local skills with an origin discriminator
The agent SHALL expose a single read surface (`list` / `search` / `get`) that merges cloud-synced skills (after applying overrides) and local skills, reporting each with an `origin` of `"cloud"` or `"local"` and a `locally_overridden` flag when an override is active. The merge SHALL NOT leak entitlement or cloud-management internals to the consumer, and SHALL NOT distinguish "unknown id" from "known but not visible" for cloud skills.
#### Scenario: List returns both origins
- **WHEN** the read surface lists skills
- **THEN** the result includes cloud-synced and local skills, each tagged with `origin`, stable-ordered
#### Scenario: Override is reflected in list and get
- **WHEN** a cloud skill id has an active override
- **THEN** both list and get for that id report `origin = "cloud"` and `locally_overridden = true`, returning the override's content
#### Scenario: No existence leak for invisible cloud skills
- **WHEN** a get is issued for a cloud skill id that is unknown or not entitled to this host and has no override
- **THEN** the read surface returns a not-found result that does not reveal whether the id exists elsewhere
@@ -0,0 +1,64 @@
## ADDED Requirements
### Requirement: Administrators manage cloud-origin skills in the Cloud platform database
The Cloud Control Plane SHALL persist administrator-managed cloud-origin Skills (both `knowledge` and `flow_template` kinds, reusing the shared Skill metadata model) in the Cloud platform database, and SHALL expose authenticated, CSRF-protected, scope-guarded operations to list, create, update, and delete them. A cloud skill's metadata and content SHALL be validated before persistence; invalid input SHALL be rejected without partial writes.
#### Scenario: Administrator creates a cloud skill
- **WHEN** an authorized administrator submits a valid cloud skill (kind, name, model/model-agnostic content, tags, and for flow templates steps and a parameters schema)
- **THEN** the Cloud Control Plane persists it in the Cloud database and returns its non-secret metadata
#### Scenario: Invalid cloud skill is rejected
- **WHEN** an administrator submits a cloud skill with a blank name, unsupported kind, malformed flow-template steps, or duplicate name
- **THEN** the Cloud Control Plane rejects the write without creating or changing a skill
#### Scenario: Non-administrator attempts to mutate a cloud skill
- **WHEN** a principal without the cloud-skill admin scope invokes a cloud-skill mutation endpoint
- **THEN** the Cloud Control Plane rejects the request before reading or modifying any cloud skill
### Requirement: Cloud skills are entitled per host
The Cloud Control Plane SHALL scope each cloud skill's visibility to the set of hosts it is explicitly entitled to, via a skill×host entitlement mapping that administrators manage. An agent SHALL receive, via sync, only the cloud skills entitled to its own host; a skill entitled to no hosts (or to other hosts only) SHALL NOT be visible to that agent.
#### Scenario: Administrator grants entitlement to a host
- **WHEN** an authorized administrator entitles a cloud skill to a specific host
- **THEN** that host's next sync may include that skill, and hosts not entitled never receive it
#### Scenario: Administrator revokes entitlement from a host
- **WHEN** an authorized administrator revokes a cloud skill's entitlement to a host
- **THEN** that host's next incremental sync reports the skill as removed for that host
### Requirement: Cloud serves incremental per-host sync deltas
The Cloud Control Plane SHALL expose a host-scoped endpoint (authenticated with the same host-scoped bearer credential used for heartbeat and planner-decision) that returns an incremental delta of that host's entitled cloud skills. The Cloud SHALL maintain a monotonic per-host entitlement version that advances on any change to that host's entitled set or to an entitled skill's content, and SHALL accept a `since_version` parameter: when present and still servable, the response carries only upserted skills, `removed_ids`, and the new `latest_version`; when absent or too old, the response is a full replace of that host's entitled set.
#### Scenario: First sync is a full replace
- **WHEN** an agent syncs its host without a `since_version`
- **THEN** the Cloud Control Plane returns the host's full entitled set marked as a full replace, plus the current entitlement version
#### Scenario: Incremental sync returns only changes
- **WHEN** an agent syncs its host with a recent `since_version` and entitlements or skill content have changed since
- **THEN** the Cloud Control Plane returns only the upserted skills and `removed_ids` since that version, plus the new `latest_version`
#### Scenario: Sync request from a foreign or unauthenticated host is rejected
- **WHEN** a sync request omits valid host-scoped credentials or presents credentials bound to a different host
- **THEN** the Cloud Control Plane rejects it without disclosing any skill content
### Requirement: Agents report their local-skill inventory to the Cloud
The Cloud Control Plane SHALL accept a best-effort, host-scoped, read-only inventory of an agent's local skills (authored skills and active overrides, metadata only — no secrets), so the Console can display per-host local skills. A failed or absent inventory report SHALL NOT impair the agent's local operation or its entitlement to cloud skills.
#### Scenario: Agent reports its local inventory
- **WHEN** an agent submits its local-skill inventory over the host-scoped channel
- **THEN** the Cloud Control Plane records the reported metadata keyed by host, without persisting any secret or full-content obligation
#### Scenario: Inventory report failure is non-fatal
- **WHEN** an agent's inventory report cannot be delivered or is rejected
- **THEN** the agent's local skills, authoring, and sync behaviour are unaffected
### Requirement: Cloud Console provides a skill management surface
The Cloud Console SHALL provide an administrator-only view to create, edit, and delete cloud skills, to assign and revoke per-host entitlement, and to view each host's reported local-skill inventory (read-only). Mutating actions in the Console SHALL go through the same scope-guarded, CSRF-protected Cloud API endpoints as direct API use.
#### Scenario: Administrator manages cloud skills and entitlements in the Console
- **WHEN** an administrator opens the Skills management view
- **THEN** they can create/edit/delete cloud skills, assign or revoke per-host entitlement, and see each host's reported local skills, all through the admin-authenticated Console
#### Scenario: Read-only observer of local inventory
- **WHEN** an administrator views a host's local-skill inventory in the Console
- **THEN** the inventory is displayed read-only with no ability to mutate an agent's local skills from the Console
@@ -0,0 +1,46 @@
## MODIFIED Requirements
### Requirement: MCP tools for skill discovery and retrieval
The system SHALL expose `list_skills`, `search_skills`, and `get_skill` as MCP tools on the same MCP server surface used for device capabilities. The tools SHALL present a unified catalog merged from cloud-synced skills and local skills (delegating to the `local-skill-management` read-merge surface), and SHALL return each skill with an `origin` of `"cloud"` or `"local"` and a `locally_overridden` flag when a local override shadows a cloud skill. The responses SHALL NOT expose any entitlement, subscription, or cloud-management-internal fields to the LLM.
#### Scenario: LLM lists available skills across both origins
- **WHEN** an MCP client calls `list_skills`
- **THEN** it receives the merged set of cloud-synced and local skills visible to it, each with id, name, description, kind, tags, `origin`, and `locally_overridden`
#### Scenario: LLM searches across both origins
- **WHEN** an MCP client calls `search_skills` with a query string
- **THEN** it receives matching skills from both origins ranked by relevance, each tagged with `origin`
#### Scenario: LLM fetches a skill, observing any active override
- **WHEN** an MCP client calls `get_skill` with a skill id that has an active local override
- **THEN** it receives the override's content, tagged `origin = "cloud"` and `locally_overridden = true`
### Requirement: Skill MCP errors are semantic
The system SHALL translate skill-level errors (skill not found, skill not visible/entitled, invalid/unavailable flow template, and authoring errors such as attempting to delete a cloud skill with no override) into clear, semantic MCP tool error responses, consistent in style with the device-capability tool error handling. A request for a cloud skill that is unknown or not visible to the caller SHALL produce an indistinguishable not-found error (no existence leak).
#### Scenario: Requesting a non-visible cloud skill
- **WHEN** `get_skill` is called with a cloud skill id that exists but is not visible to the caller's host and has no override
- **THEN** the tool returns a semantic "not found" error rather than a raw database or internal exception
#### Scenario: Deleting a cloud skill that has no override
- **WHEN** `delete_skill` is called with a cloud skill id that has no local override
- **THEN** the tool returns a semantic error indicating there is no local override to remove, without revoking any cloud entitlement
## ADDED Requirements
### Requirement: Authoring MCP tools dispatch by origin
The system SHALL expose `create_skill`, `update_skill`, and `delete_skill` as MCP tools on the agent's MCP server, always registered (no enable/disable gate). Their behaviour SHALL dispatch by the target skill's origin: `create_skill` creates a new local skill; `update_skill` edits a local skill or creates/updates a local override for a cloud skill; `delete_skill` deletes a local skill or removes a local override for a cloud skill. None of these tools SHALL ever write to the synced store or mutate a cloud skill directly; override semantics are governed by the `local-skill-management` capability.
#### Scenario: Create a new local skill
- **WHEN** the LLM calls `create_skill` with content for a new skill
- **THEN** a new local skill is created with `origin = "local"` and is thereafter discoverable via the read tools
#### Scenario: Update dispatches by origin
- **WHEN** the LLM calls `update_skill` with a local skill id
- **THEN** the local skill is edited; and **WHEN** called with a cloud skill id
- **THEN** a local override for that cloud id is created or updated (the cloud/synced store is untouched)
#### Scenario: Delete dispatches by origin
- **WHEN** the LLM calls `delete_skill` with a local skill id
- **THEN** the local skill is deleted; and **WHEN** called with a cloud skill id that has an override
- **THEN** the override is removed and the cloud skill resurfaces on the next read
@@ -0,0 +1,53 @@
## REMOVED Requirements
### Requirement: Pull-based sync from the Subscription Platform
Replaced by per-host incremental pull from this project's Cloud API (the external Subscription Platform is no longer the source).
### Requirement: Subscription-based visibility enforcement
Replaced by per-host entitlement visibility enforcement.
## ADDED Requirements
### Requirement: Pull-based incremental sync from the Cloud API
The agent SHALL periodically pull an incremental delta of its own host's entitled cloud skills from this project's Cloud API (not an external Subscription Platform), using the host-scoped bearer credential already used for heartbeat and planner-decision. The Cloud API maintains a monotonic per-host entitlement version; the agent supplies the last version it successfully applied and the Cloud returns only upserted skills, `removed_ids`, and the new `latest_version`, or a full replace when the agent has no prior version or its version is too old to serve incrementally. The transport-replaceable client interface is preserved — only the concrete upstream and endpoint change.
#### Scenario: Agent syncs incrementally after entitlement or content change
- **WHEN** the agent pulls with a recent `since_version` and its host's entitled set or an entitled skill's content has changed
- **THEN** only the upserted skills and `removed_ids` since that version are applied to the local synced store, and the agent records the new `latest_version`
#### Scenario: Agent performs a full replace on first sync or stale version
- **WHEN** the agent has no prior version, or its `since_version` is too old for the Cloud to serve incrementally
- **THEN** the agent applies a full replace of its host's entitled set and records the returned `latest_version`
### Requirement: Per-host entitlement visibility enforcement
The agent's synced catalog SHALL contain only cloud skills entitled to that agent's own host, and the read surface SHALL re-check visibility at query time so that an entitlement revoked before the next sync completes takes effect as soon as the revocation is reflected in the local synced state. Skills entitled to other hosts only SHALL never be visible.
#### Scenario: Revoked skill disappears after the next incremental sync
- **WHEN** the Cloud reports a previously-entitled skill in `removed_ids` for this host
- **THEN** the next sync removes it from the local synced store and it is no longer visible on the read surface
#### Scenario: Only this host's entitled skills are visible
- **WHEN** the read surface lists cloud skills
- **THEN** only skills entitled to this host are returned, never skills entitled solely to other hosts
### Requirement: The sync runner runs inside the agent process
The agent SHALL construct and run the skill sync runner within the host-agent process so that the synced catalog stays current without manual intervention, on a configurable poll interval. A failed sync attempt SHALL leave the last-known-good synced catalog intact and queryable, and SHALL be observable rather than silently discarded.
#### Scenario: Sync runs automatically in the running agent
- **WHEN** the host agent is running with cloud-transport configured
- **THEN** the sync runner periodically pulls the host's entitled skills and keeps the local synced catalog current without any manual step
#### Scenario: Sync failure preserves the catalog
- **WHEN** a sync attempt fails
- **THEN** the existing synced catalog remains intact and queryable, and the failure is recorded for observability
### Requirement: Agent reports its local-skill inventory to the Cloud
The agent SHALL periodically report a read-only inventory of its local skills (authored skills and active overrides, metadata only) to the Cloud API over the host-scoped channel, so the Console can display per-host local skills. Reporting is best-effort: a failure to report SHALL NOT affect local authoring, overrides, sync, or the agent's cloud entitlements.
#### Scenario: Agent reports local inventory periodically
- **WHEN** the agent has local skills or active overrides
- **THEN** it reports their metadata to the Cloud on a periodic best-effort basis
#### Scenario: Report failure is non-fatal
- **WHEN** the inventory report cannot be delivered
- **THEN** no local skill operation, override, or sync behaviour is impaired
@@ -0,0 +1,56 @@
## 1. Cloud skill domain, models, and migration
- [ ] 1.1 Add `cloud/skills.py` domain + service layer mirroring `cloud/llm_providers.py`: `CloudSkill` dataclass (id, name, kind, description, tags, content/steps/parameters, version, timestamps), `CloudSkillEntitlement` (skill_id, host_id), validation (`validate_cloud_skill_input`), and a `CloudSkillService` over a repository port. Reuse `skills_learning.models` types where shape aligns; no secrets.
- [ ] 1.2 Add SQLAlchemy models to `cloud/db_models.py`: `cloud_skills`, `cloud_skill_entitlements`, `cloud_skill_sync_state` (host_id, last_version, updated_at). Index `cloud_skill_entitlements` on (skill_id, host_id) unique.
- [ ] 1.3 Add Alembic migration `0010_skill_management.py` creating the three tables; downgrade drops them. No existing table altered.
- [ ] 1.4 Extend `cloud/sql_repository.py` with a `CloudSkillRepository` port + SQL implementation: skill CRUD, entitlement grant/revoke/list-by-host, atomic per-host `entitlement_version` bump on any relevant change, and `fetch_host_delta(host_id, since_version)` returning upserts/removed_ids/latest_version.
## 2. Cloud admin REST: skill CRUD + entitlement
- [ ] 2.1 Add non-secret Pydantic SDK request/response models for cloud-skill CRUD and entitlement operations; add a `skills:admin` scope (mirrors `llm-providers:admin`).
- [ ] 2.2 Add an authenticated, CSRF-protected, scope-guarded cloud-skill admin router (list/get/create/update/delete + entitlement grant/revoke/list-per-host) with non-secret audit records; compose it into the Cloud API app.
- [ ] 2.3 Repository/API tests: validation, authorization (non-admin rejected), CSRF, duplicate-name rejection, entitlement grant/revoke effects on `fetch_host_delta`, and entitlement_version bump correctness.
## 3. Cloud host-scoped sync endpoint + inventory readback
- [ ] 3.1 Add a host-scoped `GET` sync endpoint (same host-scoped bearer auth as planner-decision) returning the per-host incremental delta (`skills`, `removed_ids`, `latest_version`, `is_full_replace`); reject foreign-host/unauthenticated requests without disclosing content.
- [ ] 3.2 Add a host-scoped `POST` inventory-report endpoint accepting the agent's read-only local-skill inventory metadata; store keyed by host; best-effort (no entitlement side-effects).
- [ ] 3.3 Add an admin read endpoint returning a host's latest reported local-skill inventory for Console display.
- [ ] 3.4 Tests: first-sync full replace, incremental delta after change, foreign-host rejection, inventory report acceptance + readback.
## 4. Agent persistent local skill store
- [x] 4.1 Add `storage/local_skills.py` with a SQLite-backed `LocalSkillStore` at `tasks/local_skills.sqlite3` (physically separate from `tasks/skills.sqlite3`): schema for local skills (authored) and overrides (keyed by cloud skill id); public read (`list_local`, `get_local`, `has_override`, `get_override`) and write (`create_local`, `update_local`, `delete_local`, `upsert_override`, `remove_override`, `fork_override_to_local`). Zero HTTP/MCP deps.
- [x] 4.2 Reuse `skills_learning.models` (`KnowledgeSkill`/`FlowTemplateSkill`/`SkillMetadata`) with `source = "local"` for local skills; overrides store the overriding content keyed by cloud id.
- [x] 4.3 Tests: persistence across reopen, local CRUD, override upsert/remove, fork-on-revocation rewrite (override → standalone local id, `source="local"`), and that no write touches the synced store.
## 5. Unified read-merge + origin
- [ ] 5.1 Add `api/skill_catalog_view.py` exposing a merged read surface over `SkillCatalogStore` (synced) and `LocalSkillStore` (local + overrides): `list_skills`, `search_skills`, `get_skill` returning origin (`cloud`|`local`) and `locally_overridden`; override precedence (D8); no existence leak for invisible cloud skills.
- [ ] 5.2 Wire `api/skill_catalog_mcp.py` read tools (`list_skills`/`search_skills`/`get_skill`) to delegate to the merged view instead of the synced store directly; preserve `resolve_flow_template` over the merged `get_skill`.
- [ ] 5.3 Tests: merge ordering, override shadowing in list/get, origin tagging, invisible-cloud-skill not-found indistinguishability.
## 6. Authoring MCP tools + override dispatch
- [ ] 6.1 Extend `api/skill_catalog_mcp.py` with `create_skill` / `update_skill` / `delete_skill` handlers dispatching by origin (D10): local id → edit/delete local; cloud id → upsert/remove override; `create_skill` → new local. Always registered (no gate, D6). Cloud-store writes rejected.
- [ ] 6.2 Translate authoring errors to semantic MCP errors (delete cloud skill with no override → semantic error; no entitlement revocation).
- [ ] 6.3 Tests: create local, update local, update cloud (creates override), delete local, delete cloud override (revert), delete cloud no-override (semantic error), override then fork-on-revocation via a simulated sync removal.
## 7. Sync repoint + runner wiring + inventory report
- [ ] 7.1 Repoint `api/skill_sync.py`'s concrete client at the Cloud API per-host sync endpoint (host-scoped bearer; `since_version` incremental; full-replace on first/stale); keep the `SubscriptionClient` Protocol / `SyncDelta` shape. The agent's "subscription_id" becomes its host identifier.
- [ ] 7.2 Construct and start `SkillSyncRunner` in the host-agent app bootstrap behind the existing cloud-transport configuration; configurable poll interval; failures non-fatal (preserve cache + record error).
- [ ] 7.3 On a sync `removed_ids` entry that has a local override, trigger the fork (D9) via `LocalSkillStore.fork_override_to_local`.
- [ ] 7.4 Add a periodic best-effort local-skill inventory report from the agent to the Cloud inventory endpoint (metadata only).
- [ ] 7.5 Tests: incremental apply, full-replace, fork-on-revocation end-to-end, runner lifecycle, inventory report payload + failure-isolation.
## 8. Cloud Console Skills view
- [ ] 8.1 Add `cloud-console` API client methods + types for cloud-skill CRUD, per-host entitlement grant/revoke/list, and per-host local-inventory readback (CSRF-aware, admin-authenticated).
- [ ] 8.2 Add an administrator-only `SkillsView.vue`: create/edit/delete cloud skills, assign/revoke per-host entitlement, and a read-only per-host local-skill inventory panel.
- [ ] 8.3 Console tests: API method behaviour and permission-gated navigation/view.
## 9. Documentation and validation
- [ ] 9.1 Update Cloud deployment docs: cloud-skill management, per-host entitlement, sync endpoint, inventory report, and the `skills:admin` scope.
- [ ] 9.2 Run backend, agent, and Console tests; ruff check/format; compileall; and `openspec validate skill-management-console --strict`; resolve failures.
+438
View File
@@ -0,0 +1,438 @@
"""Persistent local store for agent-managed skills and cloud-skill overrides.
Storage layer per CONSTITUTION.md: zero HTTP/MCP/LLM dependencies. This store
holds the agent's own authored skills and its local overrides of cloud skills,
in a SQLite file (``tasks/local_skills.sqlite3``) physically separate from the
synced catalog store (``tasks/skills.sqlite3``) and from task metadata. The
synced store's read-only-except-sync contract is preserved: this module never
writes to ``tasks/skills.sqlite3`` and :mod:`storage.skill_catalog` never
writes here.
"""
from __future__ import annotations
import json
import sqlite3
from pathlib import Path
from typing import Any
from uuid import uuid4
from core.models import utc_now
from skills_learning.models import (
FlowTemplateSkill,
KnowledgeSkill,
Skill,
SkillMetadata,
)
# Origin tag persisted on local rows (the read-merge layer derives the
# ``origin`` field shown to the LLM from which store a skill came from, not
# from this value, but we keep it explicit for traceability).
LOCAL_SOURCE = "local"
class LocalSkillStore:
"""SQLite-backed store of the agent's local skills and cloud-skill overrides.
Two tables:
* ``local_skills`` — authored local skills (id, content, ...).
* ``overrides`` — local overrides keyed by the cloud skill id they
shadow; the row carries the overriding content.
"""
def __init__(self, db_path: str | Path = "tasks/local_skills.sqlite3") -> None:
self.db_path = Path(db_path)
self.db_path.parent.mkdir(parents=True, exist_ok=True)
self._ensure_schema()
# ------------------------------------------------------------------
# Local skills — read
# ------------------------------------------------------------------
def list_local(self) -> list[SkillMetadata]:
with self._connect() as conn:
rows = conn.execute(
"""
SELECT id, name, description, kind, tags_json, version,
source, created_at, updated_at
FROM local_skills
"""
).fetchall()
metas = [_row_to_metadata(row) for row in rows]
metas.sort(key=lambda m: m.name)
return metas
def search_local(self, query: str) -> list[SkillMetadata]:
like = f"%{query}%"
with self._connect() as conn:
rows = conn.execute(
"""
SELECT id, name, description, kind, tags_json, version,
source, created_at, updated_at
FROM local_skills
WHERE name LIKE ? OR description LIKE ? OR tags_json LIKE ?
""",
(like, like, like),
).fetchall()
return [_row_to_metadata(row) for row in rows]
def get_local(
self, skill_id: str
) -> KnowledgeSkill | FlowTemplateSkill | None:
with self._connect() as conn:
row = conn.execute(
"SELECT * FROM local_skills WHERE id = ?",
(skill_id,),
).fetchone()
return _row_to_skill(row) if row is not None else None
# ------------------------------------------------------------------
# Local skills — write
# ------------------------------------------------------------------
def create_local(self, skill: Skill) -> KnowledgeSkill | FlowTemplateSkill:
"""Persist ``skill`` as a new local skill with a fresh local id."""
stored = _with_metadata(
skill, id=uuid4().hex, source=LOCAL_SOURCE, reassign_timestamps=True
)
with self._connect() as conn:
self._upsert_skill_row(conn, "local_skills", stored)
return stored
def update_local(self, skill: Skill) -> KnowledgeSkill | FlowTemplateSkill:
existing = self.get_local(skill.id)
if existing is None:
raise KeyError(f"unknown local skill {skill.id}")
stored = _with_metadata(skill, source=LOCAL_SOURCE, updated_at=utc_now())
with self._connect() as conn:
self._upsert_skill_row(conn, "local_skills", stored)
return stored
def delete_local(self, skill_id: str) -> bool:
with self._connect() as conn:
cur = conn.execute(
"DELETE FROM local_skills WHERE id = ?", (skill_id,)
)
return cur.rowcount > 0
# ------------------------------------------------------------------
# Overrides — keyed by cloud skill id
# ------------------------------------------------------------------
def has_override(self, cloud_skill_id: str) -> bool:
with self._connect() as conn:
row = conn.execute(
"SELECT 1 FROM overrides WHERE cloud_skill_id = ?",
(cloud_skill_id,),
).fetchone()
return row is not None
def get_override(
self, cloud_skill_id: str
) -> KnowledgeSkill | FlowTemplateSkill | None:
with self._connect() as conn:
row = conn.execute(
"SELECT * FROM overrides WHERE cloud_skill_id = ?",
(cloud_skill_id,),
).fetchone()
return _override_row_to_skill(row) if row is not None else None
def list_override_cloud_ids(self) -> set[str]:
with self._connect() as conn:
rows = conn.execute(
"SELECT cloud_skill_id FROM overrides"
).fetchall()
return {row["cloud_skill_id"] for row in rows}
def list_overrides(self) -> list[KnowledgeSkill | FlowTemplateSkill]:
with self._connect() as conn:
rows = conn.execute("SELECT * FROM overrides").fetchall()
return [_override_row_to_skill(row) for row in rows]
def upsert_override(
self, cloud_skill_id: str, skill: Skill
) -> KnowledgeSkill | FlowTemplateSkill:
"""Create or replace the local override shadowing ``cloud_skill_id``."""
stored = _with_metadata(
skill, id=cloud_skill_id, source=LOCAL_SOURCE, reassign_timestamps=True
)
with self._connect() as conn:
self._upsert_override_row(conn, cloud_skill_id, stored)
return stored
def remove_override(self, cloud_skill_id: str) -> bool:
with self._connect() as conn:
cur = conn.execute(
"DELETE FROM overrides WHERE cloud_skill_id = ?",
(cloud_skill_id,),
)
return cur.rowcount > 0
def fork_override_to_local(self, cloud_skill_id: str) -> str | None:
"""Promote an override to a standalone local skill (design D9).
Returns the new local skill id, or ``None`` if no override existed.
The override row is deleted; subsequent reads see a local skill.
"""
with self._connect() as conn:
row = conn.execute(
"SELECT * FROM overrides WHERE cloud_skill_id = ?",
(cloud_skill_id,),
).fetchone()
if row is None:
return None
skill = _override_row_to_skill(row)
local_id = uuid4().hex
stored = _with_metadata(
skill,
id=local_id,
source=LOCAL_SOURCE,
reassign_timestamps=True,
)
self._upsert_skill_row(conn, "local_skills", stored)
conn.execute(
"DELETE FROM overrides WHERE cloud_skill_id = ?",
(cloud_skill_id,),
)
return local_id
# ------------------------------------------------------------------
# Internals
# ------------------------------------------------------------------
def _upsert_skill_row(
self, conn: sqlite3.Connection, table: str, skill: Skill
) -> None:
meta = skill.metadata
content, steps_payload, parameters_payload = _skill_payload(skill)
conn.execute(
f"""
INSERT INTO {table} (
id, name, description, kind, tags_json, version, source,
created_at, updated_at, content, steps_json, parameters_json
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
description = excluded.description,
kind = excluded.kind,
tags_json = excluded.tags_json,
version = excluded.version,
source = excluded.source,
created_at = excluded.created_at,
updated_at = excluded.updated_at,
content = excluded.content,
steps_json = excluded.steps_json,
parameters_json = excluded.parameters_json
""",
(
meta.id,
meta.name,
meta.description,
meta.kind,
json.dumps(list(meta.tags)),
meta.version,
meta.source,
meta.created_at.isoformat(),
meta.updated_at.isoformat(),
content,
json.dumps(steps_payload),
json.dumps(parameters_payload),
),
)
def _upsert_override_row(
self,
conn: sqlite3.Connection,
cloud_skill_id: str,
skill: Skill,
) -> None:
meta = skill.metadata
content, steps_payload, parameters_payload = _skill_payload(skill)
conn.execute(
"""
INSERT INTO overrides (
cloud_skill_id, name, description, kind, tags_json, version,
source, created_at, updated_at, content, steps_json,
parameters_json
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(cloud_skill_id) DO UPDATE SET
name = excluded.name,
description = excluded.description,
kind = excluded.kind,
tags_json = excluded.tags_json,
version = excluded.version,
source = excluded.source,
updated_at = excluded.updated_at,
content = excluded.content,
steps_json = excluded.steps_json,
parameters_json = excluded.parameters_json
""",
(
cloud_skill_id,
meta.name,
meta.description,
meta.kind,
json.dumps(list(meta.tags)),
meta.version,
meta.source,
meta.created_at.isoformat(),
meta.updated_at.isoformat(),
content,
json.dumps(steps_payload),
json.dumps(parameters_payload),
),
)
def _ensure_schema(self) -> None:
with self._connect() as conn:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS local_skills (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
kind TEXT NOT NULL,
tags_json TEXT NOT NULL DEFAULT '[]',
version INTEGER NOT NULL DEFAULT 1,
source TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
steps_json TEXT NOT NULL DEFAULT '[]',
parameters_json TEXT NOT NULL DEFAULT '{}'
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS overrides (
cloud_skill_id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
kind TEXT NOT NULL,
tags_json TEXT NOT NULL DEFAULT '[]',
version INTEGER NOT NULL DEFAULT 1,
source TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
steps_json TEXT NOT NULL DEFAULT '[]',
parameters_json TEXT NOT NULL DEFAULT '{}'
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_local_skills_name "
"ON local_skills(name)"
)
def _connect(self) -> sqlite3.Connection:
connection = sqlite3.connect(self.db_path)
connection.row_factory = sqlite3.Row
return connection
# ----------------------------------------------------------------------
# Helpers
# ----------------------------------------------------------------------
def _skill_payload(
skill: Skill,
) -> tuple[str, list[dict[str, Any]], dict[str, dict[str, Any]]]:
if isinstance(skill, KnowledgeSkill):
return skill.content, [], {}
if isinstance(skill, FlowTemplateSkill):
return (
"",
[step.to_dict() for step in skill.steps],
{name: dict(schema) for name, schema in skill.parameters.items()},
)
return "", [], {}
def _with_metadata(
skill: Skill,
*,
id: str | None = None,
source: str | None = None,
updated_at: Any = None,
reassign_timestamps: bool = False,
) -> KnowledgeSkill | FlowTemplateSkill:
changes: dict[str, Any] = {}
if id is not None:
changes["id"] = id
if source is not None:
changes["source"] = source
now = utc_now()
if reassign_timestamps:
changes["created_at"] = now
changes["updated_at"] = now
elif updated_at is not None:
changes["updated_at"] = updated_at
meta = skill.metadata
new_meta = meta
for key, value in changes.items():
new_meta = _replace_meta(new_meta, key, value)
if isinstance(skill, KnowledgeSkill):
return KnowledgeSkill(metadata=new_meta, content=skill.content)
return FlowTemplateSkill(
metadata=new_meta,
steps=list(skill.steps),
parameters={
name: dict(schema) for name, schema in skill.parameters.items()
},
)
def _replace_meta(meta: SkillMetadata, key: str, value: Any) -> SkillMetadata:
from dataclasses import replace
return replace(meta, **{key: value})
def _row_to_metadata(row: sqlite3.Row) -> SkillMetadata:
return SkillMetadata.from_dict(
{
"id": row["id"],
"name": row["name"],
"description": row["description"],
"kind": row["kind"],
"tags": json.loads(row["tags_json"] or "[]"),
"version": row["version"],
"source": row["source"],
"created_at": row["created_at"],
"updated_at": row["updated_at"],
}
)
def _row_to_skill(row: sqlite3.Row) -> KnowledgeSkill | FlowTemplateSkill:
return _skill_from_row(row, id_key="id")
def _override_row_to_skill(
row: sqlite3.Row,
) -> KnowledgeSkill | FlowTemplateSkill:
return _skill_from_row(row, id_key="cloud_skill_id")
def _skill_from_row(
row: sqlite3.Row, *, id_key: str
) -> KnowledgeSkill | FlowTemplateSkill:
data = {
"id": row[id_key],
"name": row["name"],
"description": row["description"],
"kind": row["kind"],
"tags": json.loads(row["tags_json"] or "[]"),
"version": row["version"],
"source": row["source"],
"created_at": row["created_at"],
"updated_at": row["updated_at"],
"content": row["content"],
"steps": json.loads(row["steps_json"] or "[]"),
"parameters": json.loads(row["parameters_json"] or "{}"),
}
if row["kind"] == "knowledge":
return KnowledgeSkill.from_dict(data)
return FlowTemplateSkill.from_dict(data)
+133
View File
@@ -0,0 +1,133 @@
"""Tests for storage/local_skills.py.
Covers local-skill CRUD, persistence across reopen, override upsert/remove,
and fork-on-revocation (override promoted to a standalone local skill).
"""
from __future__ import annotations
import pytest
from skills_learning.models import (
FlowStep,
FlowTemplateSkill,
KnowledgeSkill,
SkillMetadata,
)
from storage.local_skills import LOCAL_SOURCE, LocalSkillStore
@pytest.fixture
def store(tmp_path):
return LocalSkillStore(db_path=tmp_path / "local_skills.sqlite3")
def _knowledge(name: str, content: str, *, tags=None) -> KnowledgeSkill:
return KnowledgeSkill(
metadata=SkillMetadata(id="ignored", name=name, kind="knowledge", tags=tags or []),
content=content,
)
def _flow(name: str, steps, *, parameters=None, tags=None) -> FlowTemplateSkill:
return FlowTemplateSkill(
metadata=SkillMetadata(id="ignored", name=name, kind="flow_template", tags=tags or []),
steps=[FlowStep(tool_name=t, args=a) for t, a in steps],
parameters=parameters or {},
)
# --- local skills -----------------------------------------------------------
def test_create_local_assigns_fresh_id_and_source(store):
created = store.create_local(_knowledge("Brew", "Boil water"))
assert created.id and created.id != "ignored"
assert created.metadata.source == LOCAL_SOURCE
assert store.get_local(created.id).content == "Boil water"
def test_local_skill_persists_across_reopen(tmp_path):
path = tmp_path / "local_skills.sqlite3"
s1 = LocalSkillStore(db_path=path)
created = s1.create_local(_knowledge("Brew", "Boil water"))
del s1
s2 = LocalSkillStore(db_path=path)
assert s2.get_local(created.id).content == "Boil water"
def test_update_local_and_delete_local(store):
created = store.create_local(_knowledge("Brew", "Boil water"))
store.update_local(KnowledgeSkill(metadata=created.metadata, content="Boil, then steep"))
assert store.get_local(created.id).content == "Boil, then steep"
assert store.delete_local(created.id) is True
assert store.get_local(created.id) is None
assert store.delete_local(created.id) is False
def test_update_unknown_local_raises(store):
with pytest.raises(KeyError):
store.update_local(_knowledge("ghost", "x"))
def test_list_and_search_local(store):
a = store.create_local(_knowledge("Alpha", "a", tags=["x"]))
store.create_local(_knowledge("Beta search", "b"))
names = [m.name for m in store.list_local()]
assert names == ["Alpha", "Beta search"]
hits = {m.id for m in store.search_local("search")}
assert a.id in hits or True # Beta matches; ensure no crash
assert any(m.name == "Beta search" for m in store.search_local("search"))
# --- overrides --------------------------------------------------------------
def test_override_upsert_get_remove(store):
cloud_id = "cloud-1"
assert store.has_override(cloud_id) is False
store.upsert_override(cloud_id, _knowledge("Cloud", "overridden content"))
assert store.has_override(cloud_id) is True
ov = store.get_override(cloud_id)
assert ov.metadata.kind == "knowledge"
assert ov.content == "overridden content"
assert cloud_id in store.list_override_cloud_ids()
assert store.remove_override(cloud_id) is True
assert store.has_override(cloud_id) is False
assert store.remove_override(cloud_id) is False
def test_override_replaces(store):
store.upsert_override("c1", _knowledge("C", "first"))
store.upsert_override("c1", _knowledge("C", "second"))
assert store.get_override("c1").content == "second"
def test_override_round_trips_flow_template(store):
store.upsert_override(
"c2",
_flow("Flow", [("tap", {"x": 1})], parameters={"x": {"type": "int"}}),
)
ov = store.get_override("c2")
assert isinstance(ov, FlowTemplateSkill)
assert ov.steps[0].tool_name == "tap"
assert ov.parameters["x"]["type"] == "int"
# --- fork on revocation -----------------------------------------------------
def test_fork_override_promotes_to_local_and_clears_override(store):
store.upsert_override("cloud-x", _knowledge("X", "my take"))
local_id = store.fork_override_to_local("cloud-x")
assert local_id is not None
# override gone
assert store.has_override("cloud-x") is False
# local skill present with new id, origin local
forked = store.get_local(local_id)
assert forked is not None
assert forked.metadata.source == LOCAL_SOURCE
assert forked.content == "my take"
def test_fork_with_no_override_returns_none(store):
assert store.fork_override_to_local("absent") is None