Add community node protocol spec, Agent Skills, and make the failing tests hermetic

Community nodes (Phase 1 of plan.md): the protocol specification in
services/nodes/spec.py as the single source of truth, GET /nodes/spec.json,
a generated protocol docs page, the Community nodes API docs group, and the
implementation plan plus the residential proxy research. The no-open-proxy rule
is enforced in the spec (open_proxy cannot be set).

Agent Skills: register the Skills API docs group, cascade skills on account
deletion, add the nested CLAUDE.md, README and routes docs, and cover the
parser, the fetcher (end to end against a mock origin), the data layer and the
routes with 73 unit and 16 api tests.

Tests made hermetic with the repo's existing patterns instead of ambient
environment: embeddings tests set OPENROUTER_API_KEY via monkeypatch, the
workspace tests install FakeBackend via runtime.set_backend, and the skills
install tests use run_async. DockerCliBackend now returns 127 (command not
found) when the CLI is missing instead of raising FileNotFoundError, so a host
without docker answers with a clean error rather than a 500. The Providers tab
empty state no longer names the Model routes tab.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 07:13:25 +02:00
co-authored by Claude Sonnet 5.5
parent cae139ead9
commit f55c7ee5f4
102 changed files with 7414 additions and 65 deletions
+40
View File
@@ -1849,6 +1849,7 @@ def init_db():
)
_ensure_moderation_tables()
_ensure_skills_table()
for table in db.tables:
_uid_index(db, table)
@@ -2151,6 +2152,45 @@ MODERATION_INDEXES: tuple[tuple[str, str, list[str]], ...] = (
)
SKILLS_COLUMNS: tuple[tuple[str, object], ...] = (
("uid", ""),
("user_uid", ""),
("name", ""),
("description", ""),
("body", ""),
("files", ""),
("source_url", ""),
("license", ""),
("compatibility", ""),
("metadata", ""),
("allowed_tools", ""),
("enabled", 1),
("sha256", ""),
("skipped", ""),
("warnings", ""),
("fetched_at", ""),
("created_at", ""),
("updated_at", ""),
("deleted_at", ""),
("deleted_by", ""),
)
def _ensure_skills_table() -> None:
table = get_table("skills")
for column, example in SKILLS_COLUMNS:
if not table.has_column(column):
table.create_column_by_example(column, example)
_index(db, "skills", "idx_skills_user_name", ["user_uid", "name"], unique=True)
_index(
db,
"skills",
"idx_skills_user_live",
["user_uid", "enabled"],
where="deleted_at IS NULL",
)
def _ensure_moderation_tables() -> None:
for table_name, columns in MODERATION_COLUMNS.items():
table = get_table(table_name)