Build the Skills Store (#7990)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Agents increasingly depend on reusable skills, so the control plane needs a first-class way to browse, inspect, install, version, and attach those skills. > - The old skills surface was mostly operational plumbing; it did not give operators a store-like discovery flow, canonical detail URLs, rich source/version context, or creation paths. > - The backend also needed stronger contracts around company skill metadata, versions, install counts, runtime materialization, and adapter skill preferences. > - This pull request builds the Skills Store foundation across DB, shared contracts, server routes/services, UI, and Storybook. > - The benefit is a more inspectable, operator-friendly skill workflow that still preserves company-scoped control-plane boundaries and agent runtime behavior. ## Linked Issues or Issue Description No GitHub issue exists for this Paperclip work item. Paperclip task refs: PAP-10846 and PAP-10921. Feature request: Paperclip operators need a single Skills Store experience where company skills can be discovered, inspected, created, versioned, installed, and attached to agents without relying on scattered operational screens or implicit runtime state. Related PR search: - Searched GitHub for `Skills Store`, `company skills`, and `skill detail`. - Found several open skills-related PRs such as #7809 and #4409, but no duplicate PR for this end-to-end Skills Store branch. ## What Changed - Added the Skills Store backend foundation: company skill schema fields, migrations, shared types/validators, and expanded server skill routes/services. - Added skill discovery, category navigation, canonical skill detail routes, tabs, source attribution, version snapshots/diffs, install count backfill, and creation flows. - Updated agent skill preference handling so version selections survive runtime mention injection and runtime skill materialization honors pinned versions. - Preserved unversioned skill assignments as live/current selections instead of silently pinning them to the current version at assignment time. - Added focused regression coverage for company skill routes/services, route helpers, UI behavior, skill version diffs, and runtime skill version pins. - Added Storybook coverage for Skills Store discovery/detail states and updated the main layout navigation. - Addressed Greptile findings around version creation races, soft-deleted comments, fork metadata scoping, GitHub skill directory fallback, runtime snapshot materialization, shared runtime skill-selection helpers, and version-assignment semantics. ## Verification - `pnpm exec vitest run server/src/__tests__/heartbeat-project-env.test.ts server/src/__tests__/heartbeat-runtime-skills.test.ts` - `pnpm exec vitest run packages/shared/src/validators/company-skill.test.ts` - `pnpm exec vitest run server/src/__tests__/company-portability.test.ts server/src/__tests__/company-skills-service.test.ts` - `pnpm exec vitest run cli/src/__tests__/company-import-export-e2e.test.ts` - `pnpm exec vitest run server/src/__tests__/agent-skills-routes.test.ts` - `pnpm exec vitest run server/src/__tests__/openapi-routes.test.ts` - `pnpm exec vitest run server/src/__tests__/company-skills-service.test.ts server/src/__tests__/company-skills-routes.test.ts server/src/__tests__/heartbeat-runtime-skills.test.ts` - `pnpm exec vitest run server/src/__tests__/company-skills-service.test.ts` - `pnpm exec vitest run ui/src/components/IssueProperties.test.tsx -t "edits existing custom assignee model options from the properties pane"` - `pnpm --filter @paperclipai/server typecheck` - GitHub checks are green on `0823957a2`: Build, Canary Dry Run, General tests, Typecheck + Release Registry, serialized server suites, e2e, policy/review, Socket, Snyk, and aggregate `verify`. - Greptile Review succeeded on `0823957a2` with `40 files reviewed, 0 comments added`; GitHub unresolved review threads: 0. Not run in this heartbeat: - Browser screenshot capture for the UI changes. This PR intentionally omits screenshots per the Paperclip task direction not to add design screenshots/images. ## Risks - Broad feature branch touching DB, shared contracts, server, and UI; reviewers should still scan merge conflicts carefully if `master` moves again before landing. - Skill version/runtime behavior is sensitive: pinned skill versions must stay pinned while default selections should continue following the current version. - UI polish should get normal reviewer/browser attention before merge because this PR includes a large Skills Store surface and screenshots were intentionally omitted. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used OpenAI Codex, GPT-5-based coding agent with tool use and local command execution. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots (intentionally omitted per PAP-10921 direction) - [ ] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+47
-17
@@ -16,6 +16,7 @@ import {
|
||||
normalizeIssueIdentifier,
|
||||
resetAgentSessionSchema,
|
||||
testAdapterEnvironmentSchema,
|
||||
type AgentDesiredSkillEntry,
|
||||
type AgentSkillSnapshot,
|
||||
type InstanceSchedulerHeartbeatAgent,
|
||||
upsertAgentInstructionsFileSchema,
|
||||
@@ -64,6 +65,7 @@ import type {
|
||||
AdapterEnvironmentCheck,
|
||||
AdapterEnvironmentTestResult,
|
||||
} from "@paperclipai/adapter-utils";
|
||||
import { skillVersionSelectionMap } from "../services/runtime-skill-selections.js";
|
||||
import { secretService } from "../services/secrets.js";
|
||||
import {
|
||||
detectAdapterModel,
|
||||
@@ -1282,18 +1284,35 @@ export function agentRoutes(
|
||||
|
||||
function buildUnsupportedSkillSnapshot(
|
||||
adapterType: string,
|
||||
desiredSkills: string[] = [],
|
||||
desiredSkillEntries: AgentDesiredSkillEntry[] = [],
|
||||
): AgentSkillSnapshot {
|
||||
const desiredSkills = desiredSkillEntries.map((entry) => entry.key);
|
||||
return {
|
||||
adapterType,
|
||||
supported: false,
|
||||
mode: "unsupported",
|
||||
desiredSkills,
|
||||
desiredSkillEntries,
|
||||
entries: [],
|
||||
warnings: ["This adapter does not implement skill sync yet."],
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeDesiredSkillSelections(
|
||||
requestedDesiredSkills: Array<string | AgentDesiredSkillEntry> | undefined,
|
||||
): AgentDesiredSkillEntry[] | undefined {
|
||||
if (!requestedDesiredSkills) return undefined;
|
||||
const out = new Map<string, AgentDesiredSkillEntry>();
|
||||
for (const value of requestedDesiredSkills) {
|
||||
const entry = typeof value === "string"
|
||||
? { key: value.trim(), versionId: null }
|
||||
: { key: value.key.trim(), versionId: value.versionId ?? null };
|
||||
if (!entry.key || out.has(entry.key)) continue;
|
||||
out.set(entry.key, entry);
|
||||
}
|
||||
return Array.from(out.values());
|
||||
}
|
||||
|
||||
// Legacy hardcoded set — used as fallback when adapter module does not
|
||||
// declare requiresMaterializedRuntimeSkills explicitly.
|
||||
const LEGACY_MATERIALIZED_SKILLS_SET = new Set([
|
||||
@@ -1319,9 +1338,11 @@ export function agentRoutes(
|
||||
materializeMissing?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
const preference = readPaperclipSkillSyncPreference(config);
|
||||
const runtimeSkillEntries = await companySkills.listRuntimeSkillEntries(companyId, {
|
||||
materializeMissing: options.materializeMissing
|
||||
?? shouldMaterializeRuntimeSkillsForAdapter(adapterType),
|
||||
versionSelections: skillVersionSelectionMap(preference.desiredSkillEntries),
|
||||
});
|
||||
return {
|
||||
...config,
|
||||
@@ -1333,31 +1354,39 @@ export function agentRoutes(
|
||||
companyId: string,
|
||||
adapterType: string,
|
||||
adapterConfig: Record<string, unknown>,
|
||||
requestedDesiredSkills: string[] | undefined,
|
||||
requestedDesiredSkills: AgentDesiredSkillEntry[] | undefined,
|
||||
) {
|
||||
if (!requestedDesiredSkills) {
|
||||
return {
|
||||
adapterConfig,
|
||||
desiredSkills: null as string[] | null,
|
||||
desiredSkillEntries: null as AgentDesiredSkillEntry[] | null,
|
||||
runtimeSkillEntries: null as Awaited<ReturnType<typeof companySkills.listRuntimeSkillEntries>> | null,
|
||||
};
|
||||
}
|
||||
|
||||
const resolvedRequestedSkills = await companySkills.resolveRequestedSkillKeys(
|
||||
const resolvedRequestedSkillEntries = await companySkills.resolveRequestedSkillEntries(
|
||||
companyId,
|
||||
requestedDesiredSkills,
|
||||
);
|
||||
const resolvedRequestedSkills = resolvedRequestedSkillEntries.map((entry) => entry.key);
|
||||
const runtimeSkillEntries = await companySkills.listRuntimeSkillEntries(companyId, {
|
||||
materializeMissing: shouldMaterializeRuntimeSkillsForAdapter(adapterType),
|
||||
versionSelections: skillVersionSelectionMap(resolvedRequestedSkillEntries),
|
||||
});
|
||||
const requiredSkills = runtimeSkillEntries
|
||||
.filter((entry) => entry.required)
|
||||
.map((entry) => entry.key);
|
||||
const desiredSkills = Array.from(new Set([...requiredSkills, ...resolvedRequestedSkills]));
|
||||
const desiredSkillEntries = [
|
||||
...requiredSkills.map((key) => ({ key, versionId: null })),
|
||||
...resolvedRequestedSkillEntries,
|
||||
].filter((entry, index, entries) => entries.findIndex((candidate) => candidate.key === entry.key) === index);
|
||||
const desiredSkills = desiredSkillEntries.map((entry) => entry.key);
|
||||
|
||||
return {
|
||||
adapterConfig: writePaperclipSkillSyncPreference(adapterConfig, desiredSkills),
|
||||
adapterConfig: writePaperclipSkillSyncPreference(adapterConfig, desiredSkillEntries),
|
||||
desiredSkills,
|
||||
desiredSkillEntries,
|
||||
runtimeSkillEntries,
|
||||
};
|
||||
}
|
||||
@@ -1574,9 +1603,14 @@ export function agentRoutes(
|
||||
);
|
||||
const runtimeSkillEntries = await companySkills.listRuntimeSkillEntries(agent.companyId, {
|
||||
materializeMissing: false,
|
||||
versionSelections: skillVersionSelectionMap(preference.desiredSkillEntries),
|
||||
});
|
||||
const requiredSkills = runtimeSkillEntries.filter((entry) => entry.required).map((entry) => entry.key);
|
||||
res.json(buildUnsupportedSkillSnapshot(agent.adapterType, Array.from(new Set([...requiredSkills, ...preference.desiredSkills]))));
|
||||
const desiredSkillEntries = [
|
||||
...requiredSkills.map((key) => ({ key, versionId: null })),
|
||||
...preference.desiredSkillEntries,
|
||||
].filter((entry, index, entries) => entries.findIndex((candidate) => candidate.key === entry.key) === index);
|
||||
res.json(buildUnsupportedSkillSnapshot(agent.adapterType, desiredSkillEntries));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1611,16 +1645,11 @@ export function agentRoutes(
|
||||
}
|
||||
await assertCanUpdateAgent(req, agent);
|
||||
|
||||
const requestedSkills = Array.from(
|
||||
new Set(
|
||||
(req.body.desiredSkills as string[])
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean),
|
||||
),
|
||||
);
|
||||
const requestedSkills = normalizeDesiredSkillSelections(req.body.desiredSkills);
|
||||
const {
|
||||
adapterConfig: nextAdapterConfig,
|
||||
desiredSkills,
|
||||
desiredSkillEntries,
|
||||
runtimeSkillEntries,
|
||||
} = await resolveDesiredSkillAssignment(
|
||||
agent.companyId,
|
||||
@@ -1628,7 +1657,7 @@ export function agentRoutes(
|
||||
agent.adapterConfig as Record<string, unknown>,
|
||||
requestedSkills,
|
||||
);
|
||||
if (!desiredSkills || !runtimeSkillEntries) {
|
||||
if (!desiredSkills || !desiredSkillEntries || !runtimeSkillEntries) {
|
||||
throw unprocessable("Skill sync requires desiredSkills.");
|
||||
}
|
||||
const actor = getActorInfo(req);
|
||||
@@ -1669,7 +1698,7 @@ export function agentRoutes(
|
||||
adapterType: updated.adapterType,
|
||||
config: runtimeSkillConfig,
|
||||
})
|
||||
: buildUnsupportedSkillSnapshot(updated.adapterType, desiredSkills);
|
||||
: buildUnsupportedSkillSnapshot(updated.adapterType, desiredSkillEntries);
|
||||
|
||||
await logActivity(db, {
|
||||
companyId: updated.companyId,
|
||||
@@ -1683,6 +1712,7 @@ export function agentRoutes(
|
||||
details: {
|
||||
adapterType: updated.adapterType,
|
||||
desiredSkills,
|
||||
desiredSkillEntries,
|
||||
mode: snapshot.mode,
|
||||
supported: snapshot.supported,
|
||||
entryCount: snapshot.entries.length,
|
||||
@@ -2093,7 +2123,7 @@ export function agentRoutes(
|
||||
companyId,
|
||||
hireInput.adapterType,
|
||||
requestedAdapterConfig,
|
||||
Array.isArray(requestedDesiredSkills) ? requestedDesiredSkills : undefined,
|
||||
normalizeDesiredSkillSelections(Array.isArray(requestedDesiredSkills) ? requestedDesiredSkills : undefined),
|
||||
);
|
||||
const normalizedAdapterConfig = await normalizeMediatedAdapterConfigForPersistence({
|
||||
companyId,
|
||||
@@ -2279,7 +2309,7 @@ export function agentRoutes(
|
||||
companyId,
|
||||
createInput.adapterType,
|
||||
requestedAdapterConfig,
|
||||
Array.isArray(requestedDesiredSkills) ? requestedDesiredSkills : undefined,
|
||||
normalizeDesiredSkillSelections(Array.isArray(requestedDesiredSkills) ? requestedDesiredSkills : undefined),
|
||||
);
|
||||
const normalizedAdapterConfig = await normalizeMediatedAdapterConfigForPersistence({
|
||||
companyId,
|
||||
|
||||
@@ -2,13 +2,19 @@ import { Router, type Request } from "express";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
catalogSkillListQuerySchema,
|
||||
companySkillCommentCreateSchema,
|
||||
companySkillCommentUpdateSchema,
|
||||
companySkillCreateSchema,
|
||||
companySkillFileUpdateSchema,
|
||||
companySkillForkSchema,
|
||||
companySkillImportSchema,
|
||||
companySkillInstallCatalogSchema,
|
||||
companySkillInstallUpdateSchema,
|
||||
companySkillListQuerySchema,
|
||||
companySkillProjectScanRequestSchema,
|
||||
companySkillResetSchema,
|
||||
companySkillUpdateSchema,
|
||||
companySkillVersionCreateSchema,
|
||||
} from "@paperclipai/shared";
|
||||
import { trackSkillImported } from "@paperclipai/shared/telemetry";
|
||||
import { validate } from "../middleware/validate.js";
|
||||
@@ -63,6 +69,22 @@ export function companySkillRoutes(db: Db) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function queryStringArray(value: unknown): string[] {
|
||||
if (typeof value === "string") return [value];
|
||||
if (Array.isArray(value)) return value.filter((entry): entry is string => typeof entry === "string");
|
||||
return [];
|
||||
}
|
||||
|
||||
function skillActor(req: Request) {
|
||||
if (req.actor.type === "agent") {
|
||||
return { type: "agent" as const, agentId: req.actor.agentId ?? null };
|
||||
}
|
||||
if (req.actor.type === "board") {
|
||||
return { type: "user" as const, userId: req.actor.userId ?? null };
|
||||
}
|
||||
return { type: "system" as const };
|
||||
}
|
||||
|
||||
async function assertCanMutateCompanySkills(req: Request, companyId: string) {
|
||||
assertCompanyAccess(req, companyId);
|
||||
|
||||
@@ -118,15 +140,30 @@ export function companySkillRoutes(db: Db) {
|
||||
router.get("/companies/:companyId/skills", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const result = await svc.list(companyId);
|
||||
const result = await svc.list(companyId, companySkillListQuerySchema.parse({
|
||||
q: firstQueryString(req.query.q),
|
||||
sort: firstQueryString(req.query.sort),
|
||||
categories: [
|
||||
...queryStringArray(req.query.category),
|
||||
...queryStringArray(req.query.categories),
|
||||
...queryStringArray(req.query["categories[]"]),
|
||||
],
|
||||
scope: firstQueryString(req.query.scope),
|
||||
}));
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
router.get("/companies/:companyId/skills/categories", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
res.json(await svc.categoryCounts(companyId));
|
||||
});
|
||||
|
||||
router.get("/companies/:companyId/skills/:skillId", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
const skillId = req.params.skillId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const result = await svc.detail(companyId, skillId);
|
||||
const result = await svc.detail(companyId, skillId, skillActor(req));
|
||||
if (!result) {
|
||||
res.status(404).json({ error: "Skill not found" });
|
||||
return;
|
||||
@@ -134,6 +171,199 @@ export function companySkillRoutes(db: Db) {
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
router.get("/companies/:companyId/skills/:skillId/versions", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
const skillId = req.params.skillId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
res.json(await svc.listVersions(companyId, skillId));
|
||||
});
|
||||
|
||||
router.get("/companies/:companyId/skills/:skillId/versions/:versionId", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
const skillId = req.params.skillId as string;
|
||||
const versionId = req.params.versionId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const result = await svc.getVersion(companyId, skillId, versionId);
|
||||
if (!result) {
|
||||
res.status(404).json({ error: "Skill version not found" });
|
||||
return;
|
||||
}
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
router.post(
|
||||
"/companies/:companyId/skills/:skillId/versions",
|
||||
validate(companySkillVersionCreateSchema),
|
||||
async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
const skillId = req.params.skillId as string;
|
||||
await assertCanMutateCompanySkills(req, companyId);
|
||||
const result = await svc.createVersion(companyId, skillId, req.body, skillActor(req));
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "company.skill_version_created",
|
||||
entityType: "company_skill_version",
|
||||
entityId: result.id,
|
||||
details: {
|
||||
skillId,
|
||||
revisionNumber: result.revisionNumber,
|
||||
label: result.label,
|
||||
},
|
||||
});
|
||||
res.status(201).json(result);
|
||||
},
|
||||
);
|
||||
|
||||
router.post("/companies/:companyId/skills/:skillId/star", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
const skillId = req.params.skillId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const result = await svc.starSkill(companyId, skillId, skillActor(req));
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "company.skill_starred",
|
||||
entityType: "company_skill",
|
||||
entityId: skillId,
|
||||
details: { starCount: result.starCount },
|
||||
});
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
router.delete("/companies/:companyId/skills/:skillId/star", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
const skillId = req.params.skillId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const result = await svc.unstarSkill(companyId, skillId, skillActor(req));
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "company.skill_unstarred",
|
||||
entityType: "company_skill",
|
||||
entityId: skillId,
|
||||
details: { starCount: result.starCount },
|
||||
});
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
router.post(
|
||||
"/companies/:companyId/skills/:skillId/fork",
|
||||
validate(companySkillForkSchema),
|
||||
async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
const skillId = req.params.skillId as string;
|
||||
await assertCanMutateCompanySkills(req, companyId);
|
||||
const result = await svc.forkSkill(companyId, skillId, req.body, skillActor(req));
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "company.skill_forked",
|
||||
entityType: "company_skill",
|
||||
entityId: result.id,
|
||||
details: {
|
||||
sourceSkillId: skillId,
|
||||
slug: result.slug,
|
||||
name: result.name,
|
||||
},
|
||||
});
|
||||
res.status(201).json(result);
|
||||
},
|
||||
);
|
||||
|
||||
router.get("/companies/:companyId/skills/:skillId/comments", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
const skillId = req.params.skillId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
res.json(await svc.listComments(companyId, skillId));
|
||||
});
|
||||
|
||||
router.post(
|
||||
"/companies/:companyId/skills/:skillId/comments",
|
||||
validate(companySkillCommentCreateSchema),
|
||||
async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
const skillId = req.params.skillId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const result = await svc.createComment(companyId, skillId, req.body, skillActor(req));
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "company.skill_comment_created",
|
||||
entityType: "company_skill_comment",
|
||||
entityId: result.id,
|
||||
details: { skillId, parentCommentId: result.parentCommentId },
|
||||
});
|
||||
res.status(201).json(result);
|
||||
},
|
||||
);
|
||||
|
||||
router.patch(
|
||||
"/companies/:companyId/skills/:skillId/comments/:commentId",
|
||||
validate(companySkillCommentUpdateSchema),
|
||||
async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
const skillId = req.params.skillId as string;
|
||||
const commentId = req.params.commentId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const result = await svc.updateComment(companyId, skillId, commentId, req.body, skillActor(req));
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "company.skill_comment_updated",
|
||||
entityType: "company_skill_comment",
|
||||
entityId: result.id,
|
||||
details: { skillId },
|
||||
});
|
||||
res.json(result);
|
||||
},
|
||||
);
|
||||
|
||||
router.delete("/companies/:companyId/skills/:skillId/comments/:commentId", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
const skillId = req.params.skillId as string;
|
||||
const commentId = req.params.commentId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const result = await svc.deleteComment(companyId, skillId, commentId, skillActor(req));
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "company.skill_comment_deleted",
|
||||
entityType: "company_skill_comment",
|
||||
entityId: result.id,
|
||||
details: { skillId },
|
||||
});
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
router.get("/companies/:companyId/skills/:skillId/update-status", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
const skillId = req.params.skillId as string;
|
||||
@@ -165,7 +395,7 @@ export function companySkillRoutes(db: Db) {
|
||||
async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
await assertCanMutateCompanySkills(req, companyId);
|
||||
const result = await svc.createLocalSkill(companyId, req.body);
|
||||
const result = await svc.createLocalSkill(companyId, req.body, skillActor(req));
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
@@ -187,6 +417,35 @@ export function companySkillRoutes(db: Db) {
|
||||
},
|
||||
);
|
||||
|
||||
router.patch(
|
||||
"/companies/:companyId/skills/:skillId",
|
||||
validate(companySkillUpdateSchema),
|
||||
async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
const skillId = req.params.skillId as string;
|
||||
await assertCanMutateCompanySkills(req, companyId);
|
||||
const result = await svc.updateSkill(companyId, skillId, req.body);
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "company.skill_updated",
|
||||
entityType: "company_skill",
|
||||
entityId: result.id,
|
||||
details: {
|
||||
slug: result.slug,
|
||||
sharingScope: result.sharingScope,
|
||||
},
|
||||
});
|
||||
|
||||
res.json(result);
|
||||
},
|
||||
);
|
||||
|
||||
router.patch(
|
||||
"/companies/:companyId/skills/:skillId/files",
|
||||
validate(companySkillFileUpdateSchema),
|
||||
@@ -199,6 +458,7 @@ export function companySkillRoutes(db: Db) {
|
||||
skillId,
|
||||
String(req.body.path ?? ""),
|
||||
String(req.body.content ?? ""),
|
||||
skillActor(req),
|
||||
);
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
|
||||
@@ -4268,7 +4268,19 @@ for (const route of [
|
||||
["get", "/api/skills/catalog/{catalogId}", "Get a catalog skill"],
|
||||
["get", "/api/skills/catalog/{catalogId}/files", "List catalog skill files"],
|
||||
["post", "/api/companies/{companyId}/skills/install-catalog", "Install a catalog skill"],
|
||||
["get", "/api/companies/{companyId}/skills/categories", "List company skill categories"],
|
||||
["post", "/api/companies/{companyId}/skills/{skillId}/audit", "Audit a company skill"],
|
||||
["patch", "/api/companies/{companyId}/skills/{skillId}", "Update a company skill"],
|
||||
["get", "/api/companies/{companyId}/skills/{skillId}/versions", "List skill versions"],
|
||||
["post", "/api/companies/{companyId}/skills/{skillId}/versions", "Create a skill version"],
|
||||
["get", "/api/companies/{companyId}/skills/{skillId}/versions/{versionId}", "Get a skill version"],
|
||||
["post", "/api/companies/{companyId}/skills/{skillId}/star", "Star a company skill"],
|
||||
["delete", "/api/companies/{companyId}/skills/{skillId}/star", "Unstar a company skill"],
|
||||
["post", "/api/companies/{companyId}/skills/{skillId}/fork", "Fork a company skill"],
|
||||
["get", "/api/companies/{companyId}/skills/{skillId}/comments", "List skill comments"],
|
||||
["post", "/api/companies/{companyId}/skills/{skillId}/comments", "Create a skill comment"],
|
||||
["patch", "/api/companies/{companyId}/skills/{skillId}/comments/{commentId}", "Update a skill comment"],
|
||||
["delete", "/api/companies/{companyId}/skills/{skillId}/comments/{commentId}", "Delete a skill comment"],
|
||||
["post", "/api/companies/{companyId}/skills/{skillId}/reset", "Reset a company skill"],
|
||||
] as const) {
|
||||
registerCurrentRoute({
|
||||
|
||||
Reference in New Issue
Block a user