DevPlace CI / test (push) Failing after 31m42s
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>
142 lines
4.2 KiB
Python
142 lines
4.2 KiB
Python
# retoor <retoor@molodetz.nl>
|
|
|
|
import json
|
|
|
|
from sqlalchemy.exc import IntegrityError
|
|
|
|
from devplacepy.services.skills import limits
|
|
|
|
from .core import _now_iso, db, get_table
|
|
from .soft_delete import soft_delete
|
|
|
|
|
|
class SkillLimitError(ValueError):
|
|
pass
|
|
|
|
|
|
def count_skills(user_uid):
|
|
if "skills" not in db.tables:
|
|
return 0
|
|
rows = db.query(
|
|
"SELECT COUNT(*) AS c FROM skills WHERE user_uid=:u AND deleted_at IS NULL",
|
|
u=user_uid,
|
|
)
|
|
return next(iter(rows))["c"]
|
|
|
|
|
|
def list_skills(user_uid):
|
|
if "skills" not in db.tables:
|
|
return []
|
|
return list(
|
|
db.query(
|
|
"SELECT * FROM skills WHERE user_uid=:u AND deleted_at IS NULL ORDER BY name",
|
|
u=user_uid,
|
|
)
|
|
)
|
|
|
|
|
|
def get_skill_row(user_uid, name):
|
|
if "skills" not in db.tables:
|
|
return None
|
|
rows = db.query(
|
|
"SELECT * FROM skills WHERE user_uid=:u AND name=:n AND deleted_at IS NULL",
|
|
u=user_uid,
|
|
n=name,
|
|
)
|
|
return next(iter(rows), None)
|
|
|
|
|
|
def enabled_skill_summaries(user_uid):
|
|
if "skills" not in db.tables:
|
|
return []
|
|
rows = db.query(
|
|
"SELECT name, description FROM skills "
|
|
"WHERE user_uid=:u AND enabled=1 AND deleted_at IS NULL ORDER BY name LIMIT :n",
|
|
u=user_uid,
|
|
n=limits.PROMPT_MAX_SKILLS,
|
|
)
|
|
return [(row["name"], row["description"]) for row in rows]
|
|
|
|
|
|
def upsert_skill(user_uid, fields):
|
|
table = get_table("skills")
|
|
name = fields["name"]
|
|
existing = table.find_one(user_uid=user_uid, name=name)
|
|
now = _now_iso()
|
|
values = {**fields, "updated_at": now, "fetched_at": now}
|
|
if existing is None:
|
|
if count_skills(user_uid) >= limits.SKILLS_PER_USER_MAX:
|
|
raise SkillLimitError(
|
|
f"You can have at most {limits.SKILLS_PER_USER_MAX} skills; delete one first."
|
|
)
|
|
from devplacepy.utils import generate_uid
|
|
|
|
row = {
|
|
**values,
|
|
"uid": generate_uid(),
|
|
"user_uid": user_uid,
|
|
"enabled": 1,
|
|
"created_at": now,
|
|
"deleted_at": None,
|
|
"deleted_by": None,
|
|
}
|
|
try:
|
|
table.insert(row)
|
|
return get_skill_row(user_uid, name), "install"
|
|
except IntegrityError:
|
|
existing = table.find_one(user_uid=user_uid, name=name)
|
|
revived = existing.get("deleted_at") is not None
|
|
if revived and count_skills(user_uid) >= limits.SKILLS_PER_USER_MAX:
|
|
raise SkillLimitError(
|
|
f"You can have at most {limits.SKILLS_PER_USER_MAX} skills; delete one first."
|
|
)
|
|
update = {"id": existing["id"], **values, "deleted_at": None, "deleted_by": None}
|
|
if revived:
|
|
update["enabled"] = 1
|
|
table.update(update, ["id"])
|
|
return get_skill_row(user_uid, name), "update"
|
|
|
|
|
|
def set_skill_enabled(user_uid, name, enabled):
|
|
row = get_skill_row(user_uid, name)
|
|
if row is None:
|
|
return None
|
|
get_table("skills").update(
|
|
{"id": row["id"], "enabled": 1 if enabled else 0, "updated_at": _now_iso()},
|
|
["id"],
|
|
)
|
|
return get_skill_row(user_uid, name)
|
|
|
|
|
|
def delete_skill(user_uid, name, deleted_by):
|
|
row = get_skill_row(user_uid, name)
|
|
if row is None:
|
|
return False
|
|
soft_delete("skills", deleted_by, uid=row["uid"])
|
|
return True
|
|
|
|
|
|
def skill_view(row, *, detail=False):
|
|
view = {
|
|
"uid": row["uid"],
|
|
"name": row["name"],
|
|
"description": row["description"],
|
|
"enabled": bool(row.get("enabled")),
|
|
"source_url": row.get("source_url") or "",
|
|
"license": row.get("license") or "",
|
|
"compatibility": row.get("compatibility") or "",
|
|
"allowed_tools": row.get("allowed_tools") or "",
|
|
"sha256": row.get("sha256") or "",
|
|
"file_count": len(json.loads(row.get("files") or "{}")),
|
|
"fetched_at": row.get("fetched_at"),
|
|
"created_at": row.get("created_at"),
|
|
"updated_at": row.get("updated_at"),
|
|
}
|
|
if detail:
|
|
view["metadata"] = json.loads(row.get("metadata") or "{}")
|
|
view["body"] = row.get("body") or ""
|
|
view["files"] = json.loads(row.get("files") or "{}")
|
|
view["skipped"] = json.loads(row.get("skipped") or "[]")
|
|
view["warnings"] = json.loads(row.get("warnings") or "[]")
|
|
return view
|