Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 10 additions & 10 deletions openspec/changes/rhess-enterprise-skills-server/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,21 +38,21 @@

## 5. Skills Catalog REST API

- [ ] 5.1 Implement `GET /api/v1/skills` with pagination (`page`, `per_page`, `sort`) — unauthenticated
- [ ] 5.2 Implement `GET /api/v1/skills/:source/:slug` returning full file tree — unauthenticated
- [ ] 5.3 Implement `GET /api/v1/skills/search?q=<query>` using `Fuse.js` via `SearchProvider` — unauthenticated
- [x] 5.1 Implement `GET /api/v1/skills` with pagination (`page`, `per_page`, `sort`) — unauthenticated
- [x] 5.2 Implement `GET /api/v1/skills/:source/:slug` returning full file tree — unauthenticated
- [x] 5.3 Implement `GET /api/v1/skills/search?q=<query>` using `Fuse.js` via `SearchProvider` — unauthenticated
- [ ] 5.4 Implement `GET /.well-known/agent-skills/index.json` (v0.2.0 schema with `name`, `type`, `description`, `url`, `digest`) — unauthenticated
- [ ] 5.4 Implement artifact serving endpoints: raw SKILL.md and tar.gz archive downloads
- [ ] 5.5 Implement `Fuse.js`-backed `SearchProvider`; wire index rebuild on every source sync
- [ ] 5.6 Implement global Fastify error handler returning `{error: {code, message}}` for all 4xx/5xx
- [ ] 5.7 Write API tests: pagination bounds, 404 on unknown skill, fuzzy search matches, `.well-known/` index shape
- [x] 5.5 Implement `Fuse.js`-backed `SearchProvider`; wire index rebuild on every source sync
- [x] 5.6 Implement global Fastify error handler returning `{error: {code, message}}` for all 4xx/5xx
- [x] 5.7 Write API tests: pagination bounds, 404 on unknown skill, fuzzy search matches, `.well-known/` index shape

## 6. Source Management REST API

- [ ] 6.1 Implement `POST /api/v1/sources`: validate slug (kebab-case, 1–64 chars), reject duplicate slug with 409, trigger initial ingestion
- [ ] 6.2 Implement `DELETE /api/v1/sources/:id`: remove source record and all associated skills in one transaction
- [ ] 6.3 Implement `POST /api/v1/sources/:id/sync`: reject concurrent sync with 409, run `ingestSource`, return sync report
- [ ] 6.4 Write API tests: duplicate slug → 409, invalid slug → 400, clone failure → 422, concurrent sync → 409, unknown source → 404
- [x] 6.1 Implement `POST /api/v1/sources`: validate slug (kebab-case, 1–64 chars), reject duplicate slug with 409, trigger initial ingestion
- [x] 6.2 Implement `DELETE /api/v1/sources/:id`: remove source record and all associated skills in one transaction
- [x] 6.3 Implement `POST /api/v1/sources/:id/sync`: reject concurrent sync with 409, run `ingestSource`, return sync report
- [x] 6.4 Write API tests: duplicate slug → 409, invalid slug → 400, clone failure → 422, concurrent sync → 409, unknown source → 404

## 7. Health & Readiness Probes

Expand Down
11 changes: 10 additions & 1 deletion src/server/db/SqliteSkillRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ function toSkill(row: SkillRow): Skill {
const VALID_SORT: Record<string, string> = {
name: "name COLLATE NOCASE ASC",
createdAt: "created_at ASC",
updatedAt: "updated_at DESC",
};

export class SqliteSkillRepository implements SkillRepository {
Expand Down Expand Up @@ -82,7 +83,15 @@ export class SqliteSkillRepository implements SkillRepository {
);
}

findAll(opts: { page?: number; perPage?: number; sort?: "name" | "createdAt" } = {}): Skill[] {
findAllUnpaged(sort: "name" | "createdAt" | "updatedAt" = "name"): Skill[] {
const orderBy = VALID_SORT[sort] ?? VALID_SORT["name"]!;
const stmt = this.db.prepare<[], SkillRow>(
`SELECT * FROM skills ORDER BY ${orderBy}`
);
return stmt.all().map(toSkill);
}

findAll(opts: { page?: number; perPage?: number; sort?: "name" | "createdAt" | "updatedAt" } = {}): Skill[] {
const { page = 1, perPage = 20, sort = "name" } = opts;
const orderBy = VALID_SORT[sort] ?? VALID_SORT["name"]!;
const safePage = Number.isFinite(page) ? Math.max(1, Math.floor(page)) : 1;
Expand Down
24 changes: 17 additions & 7 deletions src/server/db/SqliteSourceRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ export class SqliteSourceRepository implements SourceRepository {
private readonly findByIdStmt: Database.Statement<[number], SourceRow>;
private readonly findBySlugStmt: Database.Statement<[string], SourceRow>;
private readonly createStmt: Database.Statement<[string, string], { id: number }>;
private readonly updateSyncStmt: Database.Statement<[string, string | null, string | null, number]>;
private readonly updateSyncStmt: Database.Statement<[string, string, string | null, number]>;
private readonly trySetSyncingStmt: Database.Statement<[number]>;
private readonly deleteStmt: Database.Statement<[number]>;

constructor(private readonly db: Database.Database) {
Expand All @@ -49,11 +50,19 @@ export class SqliteSourceRepository implements SourceRepository {
this.createStmt = db.prepare<[string, string], { id: number }>(
"INSERT INTO sources (slug, url) VALUES (?, ?) RETURNING id"
);
this.updateSyncStmt = db.prepare<[string, string | null, string | null, number]>(
// last_synced_at is only updated to 'now' on success (status='idle');
// for all other transitions the existing timestamp is preserved.
this.updateSyncStmt = db.prepare<[string, string, string | null, number]>(
`UPDATE sources
SET sync_status = ?, last_synced_at = ?, sync_error = ?
SET sync_status = ?,
last_synced_at = CASE ? WHEN 'idle' THEN strftime('%Y-%m-%dT%H:%M:%SZ', 'now') ELSE last_synced_at END,
sync_error = ?
WHERE id = ?`
);
this.trySetSyncingStmt = db.prepare<[number]>(
`UPDATE sources SET sync_status = 'syncing'
WHERE id = ? AND sync_status != 'syncing'`
);
this.deleteStmt = db.prepare<[number]>(
"DELETE FROM sources WHERE id = ?"
);
Expand All @@ -80,17 +89,18 @@ export class SqliteSourceRepository implements SourceRepository {
}

updateSync(input: UpdateSourceSyncInput): void {
const now = input.status !== "syncing"
? new Date().toISOString()
: null;
this.updateSyncStmt.run(
input.status,
now,
input.status,
input.error ?? null,
input.id
);
}

trySetSyncing(id: number): boolean {
return this.trySetSyncingStmt.run(id).changes > 0;
}

delete(id: number): void {
this.deleteStmt.run(id);
}
Expand Down
9 changes: 8 additions & 1 deletion src/server/db/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,9 @@ export interface UpsertSkillInput {
}

export interface SkillRepository {
findAll(opts?: { page?: number; perPage?: number; sort?: "name" | "createdAt" }): Skill[];
findAll(opts?: { page?: number; perPage?: number; sort?: "name" | "createdAt" | "updatedAt" }): Skill[];
/** Return ALL skills without any pagination cap — use for search index builds. */
findAllUnpaged(sort?: "name" | "createdAt" | "updatedAt"): Skill[];
findBySourceAndSlug(sourceSlug: string, slug: string): Skill | undefined;
findBySource(sourceId: number): Skill[];
upsertMany(skills: UpsertSkillInput[]): void;
Expand All @@ -66,5 +68,10 @@ export interface SourceRepository {
findBySlug(slug: string): Source | undefined;
create(input: CreateSourceInput): Source;
updateSync(input: UpdateSourceSyncInput): void;
/**
* Atomically set sync_status to 'syncing' only when currently idle/error.
* Returns true if the transition happened (source was not already syncing).
*/
trySetSyncing(id: number): boolean;
delete(id: number): void;
}
54 changes: 50 additions & 4 deletions src/server/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import Fastify from "fastify";
import Fastify, { type FastifyError } from "fastify";
import fastifyStatic from "@fastify/static";
import fastifyCors from "@fastify/cors";
import { resolve, dirname } from "path";
import { fileURLToPath } from "url";
import { initDatabase } from "./db/init.js";
import type { Repositories } from "./db/init.js";
import { loadExamplesIfEmpty } from "./ingestion/examples.js";
import { FuseSearchProvider } from "./search/FuseSearchProvider.js";
import skillsPlugin from "./routes/skills.js";
import sourcesPlugin from "./routes/sources.js";

const __dirname = dirname(fileURLToPath(import.meta.url));

Expand Down Expand Up @@ -55,8 +58,33 @@ export async function buildServer(repos?: Repositories) {

const app = Fastify({ logger: true });

// Global error handler: all errors return {error: {code, message}}
app.setErrorHandler((err: FastifyError, _req, reply) => {
const status = err.statusCode ?? 500;
if (status < 500) {
const code = err.code === "FST_ERR_VALIDATION" ? "INVALID_PARAMS" : "BAD_REQUEST";
return reply.code(status).send({ error: { code, message: err.message } });
}
app.log.error(err);
return reply
.code(500)
.send({ error: { code: "INTERNAL_ERROR", message: "An internal error occurred." } });
});

await app.register(fastifyCors, { origin: parseCorsOrigin() });

// Build Fuse.js search index from the current catalog
const searchProvider = new FuseSearchProvider();
searchProvider.buildIndex(
db.skills.findAllUnpaged().map((s) => ({
id: s.id,
sourceSlug: s.sourceSlug,
slug: s.slug,
name: s.name,
description: s.description,
}))
);

app.get("/healthz", async () => ({ status: "ok" }));

// Readiness probe: verify the DB is reachable.
Expand All @@ -70,6 +98,20 @@ export async function buildServer(repos?: Repositories) {
}
});

// Skills catalog read API
await app.register(skillsPlugin, {
prefix: "/api/v1/skills",
skills: db.skills,
search: searchProvider,
});

// Source management API
await app.register(sourcesPlugin, {
prefix: "/api/v1/sources",
repos: db,
searchProvider,
});

await app.register(fastifyStatic, {
root: UI_DIST,
prefix: "/",
Expand All @@ -84,8 +126,12 @@ export async function buildServer(repos?: Repositories) {
return reply.code(404).send({ error: { code: "NOT_FOUND", message: "Not found" } });
});

return app;
return { app, searchProvider };
}

const app = await buildServer();
await app.listen({ port: PORT, host: "0.0.0.0" });
// Only start the server when this file is the entry point (not imported as a module).
import { pathToFileURL } from "url";
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
const { app } = await buildServer();
await app.listen({ port: PORT, host: "0.0.0.0" });
}
176 changes: 176 additions & 0 deletions src/server/routes/skills.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { extract } from "tar";
import type { FastifyPluginAsync, FastifyRequest, FastifyReply } from "fastify";
import type { SkillRepository } from "../db/types.js";
import type { SearchProvider } from "../search/types.js";

/**
* Decode a base64 tar.gz archive and return all contained files as
* {path, contents} entries, with SKILL.md sorted first.
*/
async function expandArchiveToFiles(
base64Content: string
): Promise<Array<{ path: string; contents: string }>> {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "rhess-skill-"));
try {
const buf = Buffer.from(base64Content, "base64");
const tmpFile = path.join(tmpDir, "_archive.tar.gz");
fs.writeFileSync(tmpFile, buf);
await extract({ file: tmpFile, cwd: tmpDir });

const entries: Array<{ path: string; contents: string }> = [];
const walk = (dir: string, relBase: string) => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const absPath = path.join(dir, entry.name);
const relPath = relBase ? `${relBase}/${entry.name}` : entry.name;
if (entry.isDirectory()) {
walk(absPath, relPath);
} else if (entry.name !== "_archive.tar.gz") {
entries.push({ path: relPath, contents: fs.readFileSync(absPath, "utf-8") });
}
}
};
walk(tmpDir, "");

entries.sort((a, b) => {
if (a.path.toLowerCase() === "skill.md") return -1;
if (b.path.toLowerCase() === "skill.md") return 1;
return a.path.localeCompare(b.path);
});
return entries;
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
}

interface SkillsRouteOptions {
skills: SkillRepository;
search: SearchProvider;
}

function invalidParams(reply: FastifyReply, message: string) {
return reply.code(400).send({ error: { code: "INVALID_PARAMS", message } });
}

const skillsPlugin: FastifyPluginAsync<SkillsRouteOptions> = async (fastify, opts) => {
const { skills, search } = opts;

// Search must be registered BEFORE /:source/:slug to avoid path conflict
fastify.get(
"/search",
async (
req: FastifyRequest<{ Querystring: { q?: string } }>,
reply: FastifyReply
) => {
const { q } = req.query;
if (!q || q.trim() === "") {
return reply
.code(400)
.send({ error: { code: "MISSING_QUERY", message: "Query parameter 'q' is required." } });
}

const results = search.search(q.trim());
const data = results.map((r) => ({
id: r.id,
source: r.sourceSlug,
slug: r.slug,
name: r.name,
description: r.description,
score: r.score,
}));

return reply.send({ data });
}
);

// Paginated listing
fastify.get(
"/",
async (
req: FastifyRequest<{
Querystring: { page?: string; per_page?: string; sort?: string };
}>,
reply: FastifyReply
) => {
const rawPage = req.query.page !== undefined ? Number(req.query.page) : 1;
const rawPerPage =
req.query.per_page !== undefined ? Number(req.query.per_page) : 20;
const rawSort = req.query.sort ?? "name";

if (
!Number.isInteger(rawPage) ||
rawPage < 1
) {
return invalidParams(reply, "'page' must be a positive integer.");
}
if (
!Number.isInteger(rawPerPage) ||
rawPerPage < 1 ||
rawPerPage > 100
) {
return invalidParams(
reply,
"'per_page' must be an integer between 1 and 100."
);
}
if (rawSort !== "name" && rawSort !== "updated_at") {
return invalidParams(reply, "'sort' must be 'name' or 'updated_at'.");
}

const sort = rawSort === "updated_at" ? "updatedAt" : "name";
const data = skills.findAll({ page: rawPage, perPage: rawPerPage, sort });
const total = skills.count();

return reply.send({
data: data.map((s) => ({
id: s.id,
name: s.name,
description: s.description,
source: s.sourceSlug,
slug: s.slug,
artifactType: s.artifactType,
digest: s.digest,
})),
meta: { page: rawPage, per_page: rawPerPage, total },
});
}
);

// Skill detail (registered AFTER /search)
fastify.get(
"/:source/:slug",
async (
req: FastifyRequest<{ Params: { source: string; slug: string } }>,
reply: FastifyReply
) => {
const { source, slug } = req.params;
const skill = skills.findBySourceAndSlug(source, slug);

if (!skill) {
return reply.code(404).send({
error: { code: "SKILL_NOT_FOUND", message: `Skill '${source}/${slug}' not found.` },
});
}

const files: Array<{ path: string; contents: string }> =
skill.artifactType === "skill-md"
? [{ path: "SKILL.md", contents: skill.content }]
: await expandArchiveToFiles(skill.content);

return reply.send({
id: skill.id,
source: skill.sourceSlug,
slug: skill.slug,
name: skill.name,
description: skill.description,
artifactType: skill.artifactType,
digest: skill.digest,
files,
});
}
);
};

export default skillsPlugin;
Loading
Loading