From 35a9dc37b00574c46e31fbd624c49ba1bc5de858 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Fri, 24 Apr 2026 07:37:13 -0500 Subject: [PATCH] [codex] Speed up company skill detail loading (#4380) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - Company skills are part of the control plane for distributing reusable capabilities > - Board flows that inspect company skill detail should stay responsive because they are operator-facing control-plane reads > - The existing detail path was doing broader work than needed for the specific detail screen > - This pull request narrows that company-skill detail loading path and adds a regression test around it > - The benefit is faster company skill detail reads without changing the external API contract ## What Changed - tightened the company-skill detail loading path in `server/src/services/company-skills.ts` - added `server/src/__tests__/company-skills-detail.test.ts` to verify the detail route only pulls the required data ## Verification - `pnpm exec vitest run --project @paperclipai/server server/src/__tests__/company-skills-detail.test.ts` ## Risks - Low risk: this only changes the company-skill detail query path, but any missed assumption in the detail consumer would surface when loading that screen > 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 code execution in the Codex CLI environment ## 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 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 - [ ] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --- packages/shared/src/types/company-skill.ts | 5 + .../shared/src/validators/company-skill.ts | 4 +- .../__tests__/company-skills-detail.test.ts | 232 ++++++++++++++++++ server/src/services/company-skills.ts | 140 ++++++----- 4 files changed, 317 insertions(+), 64 deletions(-) create mode 100644 server/src/__tests__/company-skills-detail.test.ts diff --git a/packages/shared/src/types/company-skill.ts b/packages/shared/src/types/company-skill.ts index 12834083..29c3b58a 100644 --- a/packages/shared/src/types/company-skill.ts +++ b/packages/shared/src/types/company-skill.ts @@ -59,6 +59,11 @@ export interface CompanySkillUsageAgent { urlKey: string; adapterType: string; desired: boolean; + /** + * Runtime adapter skill state when a caller explicitly fetched it. + * Company skill detail reads intentionally return null here to avoid probing + * agent runtimes while loading operator-facing skill metadata. + */ actualState: string | null; } diff --git a/packages/shared/src/validators/company-skill.ts b/packages/shared/src/validators/company-skill.ts index 7f1df34b..eb1df5ce 100644 --- a/packages/shared/src/validators/company-skill.ts +++ b/packages/shared/src/validators/company-skill.ts @@ -43,7 +43,9 @@ export const companySkillUsageAgentSchema = z.object({ urlKey: z.string().min(1), adapterType: z.string().min(1), desired: z.boolean(), - actualState: z.string().nullable(), + actualState: z.string().nullable().describe( + "Runtime adapter skill state when explicitly fetched; company skill detail reads return null without probing agent runtimes.", + ), }); export const companySkillDetailSchema = companySkillSchema.extend({ diff --git a/server/src/__tests__/company-skills-detail.test.ts b/server/src/__tests__/company-skills-detail.test.ts new file mode 100644 index 00000000..726e95f2 --- /dev/null +++ b/server/src/__tests__/company-skills-detail.test.ts @@ -0,0 +1,232 @@ +import { randomUUID } from "node:crypto"; +import os from "node:os"; +import path from "node:path"; +import { promises as fs } from "node:fs"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { agents, companies, companySkills, createDb } from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { companySkillService } from "../services/company-skills.js"; + +const mockListSkills = vi.hoisted(() => vi.fn(() => new Promise(() => {}))); + +vi.mock("../adapters/index.js", async () => { + const actual = await vi.importActual("../adapters/index.js"); + return { + ...actual, + findActiveServerAdapter: vi.fn(() => ({ + listSkills: mockListSkills, + })), + }; +}); + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres company skill detail tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +describeEmbeddedPostgres("companySkillService.detail", () => { + let db!: ReturnType; + let svc!: ReturnType; + let tempDb: Awaited> | null = null; + const cleanupDirs = new Set(); + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-company-skills-detail-"); + db = createDb(tempDb.connectionString); + svc = companySkillService(db); + }, 20_000); + + afterEach(async () => { + mockListSkills.mockClear(); + await db.delete(agents); + await db.delete(companySkills); + await db.delete(companies); + await Promise.all(Array.from(cleanupDirs, (dir) => fs.rm(dir, { recursive: true, force: true }))); + cleanupDirs.clear(); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + function createTrackedDb(baseDb: ReturnType) { + const implicitCompanySkillSelects = vi.fn(); + + const trackedDb = new Proxy(baseDb, { + get(target, prop, receiver) { + if (prop !== "select") { + const value = Reflect.get(target, prop, receiver); + return typeof value === "function" ? value.bind(target) : value; + } + + return ((selection?: unknown) => { + const builder = selection === undefined ? target.select() : target.select(selection as never); + + return new Proxy(builder as object, { + get(builderTarget, builderProp, builderReceiver) { + if (builderProp !== "from") { + const value = Reflect.get(builderTarget, builderProp, builderReceiver); + return typeof value === "function" ? value.bind(builderTarget) : value; + } + + return (table: unknown) => { + const fromResult = (builderTarget as { from: (value: unknown) => unknown }).from(table); + if (table === companySkills) { + if (selection === undefined) { + implicitCompanySkillSelects(); + } + } + + return fromResult; + }; + }, + }); + }) as typeof target.select; + }, + }); + + return { + db: trackedDb as typeof baseDb, + implicitCompanySkillSelects, + }; + } + + it("reports attached agents without probing adapter runtime skill state", async () => { + const companyId = randomUUID(); + const skillId = randomUUID(); + const skillKey = `company/${companyId}/reflection-coach`; + const skillDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-reflection-skill-")); + cleanupDirs.add(skillDir); + await fs.writeFile(path.join(skillDir, "SKILL.md"), "# Reflection Coach\n", "utf8"); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await db.insert(companySkills).values({ + id: skillId, + companyId, + key: skillKey, + slug: "reflection-coach", + name: "Reflection Coach", + description: null, + markdown: "# Reflection Coach\n", + sourceType: "local_path", + sourceLocator: skillDir, + trustLevel: "markdown_only", + compatibility: "compatible", + fileInventory: [{ path: "SKILL.md", kind: "skill" }], + metadata: { sourceKind: "local_path" }, + }); + await db.insert(agents).values({ + id: randomUUID(), + companyId, + name: "Reviewer", + role: "engineer", + adapterType: "codex_local", + adapterConfig: { + paperclipSkillSync: { + desiredSkills: [skillKey], + }, + }, + }); + + const detail = await Promise.race([ + svc.detail(companyId, skillId), + new Promise((_, reject) => setTimeout(() => reject(new Error("skill detail timed out")), 1_000)), + ]); + + expect(mockListSkills).not.toHaveBeenCalled(); + expect(detail?.usedByAgents).toEqual([ + expect.objectContaining({ + name: "Reviewer", + desired: true, + actualState: null, + }), + ]); + }); + + it("uses explicit company skill column selections when resolving detail usage", async () => { + const companyId = randomUUID(); + const skillId = randomUUID(); + const skillKey = `company/${companyId}/reflection-coach`; + const skillDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-reflection-skill-")); + cleanupDirs.add(skillDir); + await fs.writeFile(path.join(skillDir, "SKILL.md"), "# Reflection Coach\n", "utf8"); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await db.insert(companySkills).values([ + { + id: skillId, + companyId, + key: skillKey, + slug: "reflection-coach", + name: "Reflection Coach", + description: null, + markdown: "# Reflection Coach\n", + sourceType: "local_path", + sourceLocator: skillDir, + trustLevel: "markdown_only", + compatibility: "compatible", + fileInventory: [{ path: "SKILL.md", kind: "skill" }], + metadata: { sourceKind: "local_path" }, + }, + { + id: randomUUID(), + companyId, + key: `company/${companyId}/large-reference-skill`, + slug: "large-reference-skill", + name: "Large Reference Skill", + description: null, + markdown: `# Large Reference Skill\n\n${"x".repeat(32_000)}`, + sourceType: "catalog", + sourceLocator: "paperclip://catalog/large-reference-skill", + trustLevel: "markdown_only", + compatibility: "compatible", + fileInventory: [{ path: "SKILL.md", kind: "skill" }], + metadata: { sourceKind: "catalog" }, + }, + ]); + await db.insert(agents).values({ + id: randomUUID(), + companyId, + name: "Reviewer", + role: "engineer", + adapterType: "codex_local", + adapterConfig: { + paperclipSkillSync: { + desiredSkills: ["reflection-coach"], + }, + }, + }); + + const tracked = createTrackedDb(db); + const trackedSvc = companySkillService(tracked.db); + const detail = await Promise.race([ + trackedSvc.detail(companyId, skillId), + new Promise((_, reject) => setTimeout(() => reject(new Error("skill detail timed out")), 1_000)), + ]); + + expect(detail?.usedByAgents).toEqual([ + expect.objectContaining({ + name: "Reviewer", + desired: true, + }), + ]); + expect(tracked.implicitCompanySkillSelects).not.toHaveBeenCalled(); + }); +}); diff --git a/server/src/services/company-skills.ts b/server/src/services/company-skills.ts index 0985c2b6..8c67a0c3 100644 --- a/server/src/services/company-skills.ts +++ b/server/src/services/company-skills.ts @@ -27,13 +27,11 @@ import type { CompanySkillUsageAgent, } from "@paperclipai/shared"; import { normalizeAgentUrlKey } from "@paperclipai/shared"; -import { findActiveServerAdapter } from "../adapters/index.js"; import { resolvePaperclipInstanceRoot } from "../home-paths.js"; import { notFound, unprocessable } from "../errors.js"; import { ghFetch, gitHubApiBase, resolveRawGitHubUrl } from "./github-fetch.js"; import { agentService } from "./agents.js"; import { projectService } from "./projects.js"; -import { secretService } from "./secrets.js"; type CompanySkillRow = typeof companySkills.$inferSelect; type CompanySkillListDbRow = Pick< @@ -72,6 +70,12 @@ type CompanySkillListRow = Pick< | "createdAt" | "updatedAt" >; +type CompanySkillReferenceRow = Pick< + CompanySkillRow, + | "id" + | "key" + | "slug" +>; type SkillReferenceTarget = Pick; type SkillSourceInfoTarget = Pick< CompanySkill, @@ -147,6 +151,27 @@ type RuntimeSkillEntryOptions = { const skillInventoryRefreshPromises = new Map>(); +function selectCompanySkillColumns() { + return { + id: companySkills.id, + companyId: companySkills.companyId, + key: companySkills.key, + slug: companySkills.slug, + name: companySkills.name, + description: companySkills.description, + markdown: companySkills.markdown, + sourceType: companySkills.sourceType, + sourceLocator: companySkills.sourceLocator, + sourceRef: companySkills.sourceRef, + trustLevel: companySkills.trustLevel, + compatibility: companySkills.compatibility, + fileInventory: companySkills.fileInventory, + metadata: companySkills.metadata, + createdAt: companySkills.createdAt, + updatedAt: companySkills.updatedAt, + }; +} + const PROJECT_SCAN_DIRECTORY_ROOTS = [ "skills", "skills/.curated", @@ -1523,7 +1548,6 @@ function toCompanySkillListItem(skill: CompanySkillListRow, attachedAgentCount: export function companySkillService(db: Db) { const agents = agentService(db); const projects = projectService(db); - const secretsSvc = secretService(db); async function ensureBundledSkills(companyId: string) { for (const skillsRoot of resolveBundledSkillsRoot()) { @@ -1553,10 +1577,19 @@ export function companySkillService(db: Db) { async function pruneMissingLocalPathSkills(companyId: string) { const rows = await db - .select() + .select({ + id: companySkills.id, + key: companySkills.key, + slug: companySkills.slug, + sourceType: companySkills.sourceType, + sourceLocator: companySkills.sourceLocator, + }) .from(companySkills) .where(eq(companySkills.companyId, companyId)); - const skills = rows.map((row) => toCompanySkill(row)); + const skills = rows.map((row) => ({ + ...row, + sourceType: row.sourceType as CompanySkillSourceType, + })); const missingIds = new Set(await findMissingLocalSkillIds(skills)); if (missingIds.size === 0) return; @@ -1628,25 +1661,37 @@ export function companySkillService(db: Db) { async function listFull(companyId: string): Promise { await ensureSkillInventoryCurrent(companyId); const rows = await db - .select() + .select(selectCompanySkillColumns()) .from(companySkills) .where(eq(companySkills.companyId, companyId)) .orderBy(asc(companySkills.name), asc(companySkills.key)); return rows.map((row) => toCompanySkill(row)); } - async function getById(id: string) { - const row = await db - .select() + async function listReferenceTargets(companyId: string): Promise { + const rows = await db + .select({ + id: companySkills.id, + key: companySkills.key, + slug: companySkills.slug, + }) .from(companySkills) - .where(eq(companySkills.id, id)) + .where(eq(companySkills.companyId, companyId)); + return rows as CompanySkillReferenceRow[]; + } + + async function getById(companyId: string, id: string) { + const row = await db + .select(selectCompanySkillColumns()) + .from(companySkills) + .where(and(eq(companySkills.companyId, companyId), eq(companySkills.id, id))) .then((rows) => rows[0] ?? null); return row ? toCompanySkill(row) : null; } async function getByKey(companyId: string, key: string) { const row = await db - .select() + .select(selectCompanySkillColumns()) .from(companySkills) .where(and(eq(companySkills.companyId, companyId), eq(companySkills.key, key))) .then((rows) => rows[0] ?? null); @@ -1654,67 +1699,36 @@ export function companySkillService(db: Db) { } async function usage(companyId: string, key: string): Promise { - const skills = await listFull(companyId); + const skills = await listReferenceTargets(companyId); const agentRows = await agents.list(companyId); const desiredAgents = agentRows.filter((agent) => { const desiredSkills = resolveDesiredSkillKeys(skills, agent.adapterConfig as Record); return desiredSkills.includes(key); }); - return Promise.all( - desiredAgents.map(async (agent) => { - const adapter = findActiveServerAdapter(agent.adapterType); - let actualState: string | null = null; - - if (!adapter?.listSkills) { - actualState = "unsupported"; - } else { - try { - const { config: runtimeConfig } = await secretsSvc.resolveAdapterConfigForRuntime( - agent.companyId, - agent.adapterConfig as Record, - ); - const runtimeSkillEntries = await listRuntimeSkillEntries(agent.companyId); - const snapshot = await adapter.listSkills({ - agentId: agent.id, - companyId: agent.companyId, - adapterType: agent.adapterType, - config: { - ...runtimeConfig, - paperclipRuntimeSkills: runtimeSkillEntries, - }, - }); - actualState = snapshot.entries.find((entry) => entry.key === key)?.state - ?? (snapshot.supported ? "missing" : "unsupported"); - } catch { - actualState = "unknown"; - } - } - - return { - id: agent.id, - name: agent.name, - urlKey: agent.urlKey, - adapterType: agent.adapterType, - desired: true, - actualState, - }; - }), - ); + return desiredAgents.map((agent) => ({ + id: agent.id, + name: agent.name, + urlKey: agent.urlKey, + adapterType: agent.adapterType, + desired: true, + // Runtime adapter state is intentionally omitted from this bounded metadata read. + actualState: null, + })); } async function detail(companyId: string, id: string): Promise { await ensureSkillInventoryCurrent(companyId); - const skill = await getById(id); - if (!skill || skill.companyId !== companyId) return null; + const skill = await getById(companyId, id); + if (!skill) return null; const usedByAgents = await usage(companyId, skill.key); return enrichSkill(skill, usedByAgents.length, usedByAgents); } async function updateStatus(companyId: string, skillId: string): Promise { await ensureSkillInventoryCurrent(companyId); - const skill = await getById(skillId); - if (!skill || skill.companyId !== companyId) return null; + const skill = await getById(companyId, skillId); + if (!skill) return null; if (skill.sourceType !== "github" && skill.sourceType !== "skills_sh") { return { @@ -1757,8 +1771,8 @@ export function companySkillService(db: Db) { async function readFile(companyId: string, skillId: string, relativePath: string): Promise { await ensureSkillInventoryCurrent(companyId); - const skill = await getById(skillId); - if (!skill || skill.companyId !== companyId) return null; + const skill = await getById(companyId, skillId); + if (!skill) return null; const normalizedPath = normalizePortablePath(relativePath || "SKILL.md"); const fileEntry = skill.fileInventory.find((entry) => entry.path === normalizedPath); @@ -1855,8 +1869,8 @@ export function companySkillService(db: Db) { async function updateFile(companyId: string, skillId: string, relativePath: string, content: string): Promise { await ensureSkillInventoryCurrent(companyId); - const skill = await getById(skillId); - if (!skill || skill.companyId !== companyId) throw notFound("Skill not found"); + const skill = await getById(companyId, skillId); + if (!skill) throw notFound("Skill not found"); const source = deriveSkillSourceInfo(skill); if (!source.editable || skill.sourceType !== "local_path") { @@ -1895,8 +1909,8 @@ export function companySkillService(db: Db) { async function installUpdate(companyId: string, skillId: string): Promise { await ensureSkillInventoryCurrent(companyId); - const skill = await getById(skillId); - if (!skill || skill.companyId !== companyId) return null; + const skill = await getById(companyId, skillId); + if (!skill) return null; const status = await updateStatus(companyId, skillId); if (!status?.supported) { @@ -2136,7 +2150,7 @@ export function companySkillService(db: Db) { return skillDir; } - function resolveRuntimeSkillMaterializedPath(companyId: string, skill: CompanySkill) { + function resolveRuntimeSkillMaterializedPath(companyId: string, skill: Pick) { const runtimeRoot = path.resolve(resolveManagedSkillsRoot(companyId), "__runtime__"); return path.resolve(runtimeRoot, buildSkillRuntimeName(skill.key, skill.slug)); }