mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-18 11:40:39 +09:00
[codex] Speed up company skill detail loading (#4380)
## 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
This commit is contained in:
parent
e4995bbb1c
commit
35a9dc37b0
4 changed files with 317 additions and 64 deletions
|
|
@ -59,6 +59,11 @@ export interface CompanySkillUsageAgent {
|
||||||
urlKey: string;
|
urlKey: string;
|
||||||
adapterType: string;
|
adapterType: string;
|
||||||
desired: boolean;
|
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;
|
actualState: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,9 @@ export const companySkillUsageAgentSchema = z.object({
|
||||||
urlKey: z.string().min(1),
|
urlKey: z.string().min(1),
|
||||||
adapterType: z.string().min(1),
|
adapterType: z.string().min(1),
|
||||||
desired: z.boolean(),
|
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({
|
export const companySkillDetailSchema = companySkillSchema.extend({
|
||||||
|
|
|
||||||
232
server/src/__tests__/company-skills-detail.test.ts
Normal file
232
server/src/__tests__/company-skills-detail.test.ts
Normal file
|
|
@ -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<typeof import("../adapters/index.js")>("../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<typeof createDb>;
|
||||||
|
let svc!: ReturnType<typeof companySkillService>;
|
||||||
|
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||||
|
const cleanupDirs = new Set<string>();
|
||||||
|
|
||||||
|
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<typeof createDb>) {
|
||||||
|
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<never>((_, 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<never>((_, 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -27,13 +27,11 @@ import type {
|
||||||
CompanySkillUsageAgent,
|
CompanySkillUsageAgent,
|
||||||
} from "@paperclipai/shared";
|
} from "@paperclipai/shared";
|
||||||
import { normalizeAgentUrlKey } from "@paperclipai/shared";
|
import { normalizeAgentUrlKey } from "@paperclipai/shared";
|
||||||
import { findActiveServerAdapter } from "../adapters/index.js";
|
|
||||||
import { resolvePaperclipInstanceRoot } from "../home-paths.js";
|
import { resolvePaperclipInstanceRoot } from "../home-paths.js";
|
||||||
import { notFound, unprocessable } from "../errors.js";
|
import { notFound, unprocessable } from "../errors.js";
|
||||||
import { ghFetch, gitHubApiBase, resolveRawGitHubUrl } from "./github-fetch.js";
|
import { ghFetch, gitHubApiBase, resolveRawGitHubUrl } from "./github-fetch.js";
|
||||||
import { agentService } from "./agents.js";
|
import { agentService } from "./agents.js";
|
||||||
import { projectService } from "./projects.js";
|
import { projectService } from "./projects.js";
|
||||||
import { secretService } from "./secrets.js";
|
|
||||||
|
|
||||||
type CompanySkillRow = typeof companySkills.$inferSelect;
|
type CompanySkillRow = typeof companySkills.$inferSelect;
|
||||||
type CompanySkillListDbRow = Pick<
|
type CompanySkillListDbRow = Pick<
|
||||||
|
|
@ -72,6 +70,12 @@ type CompanySkillListRow = Pick<
|
||||||
| "createdAt"
|
| "createdAt"
|
||||||
| "updatedAt"
|
| "updatedAt"
|
||||||
>;
|
>;
|
||||||
|
type CompanySkillReferenceRow = Pick<
|
||||||
|
CompanySkillRow,
|
||||||
|
| "id"
|
||||||
|
| "key"
|
||||||
|
| "slug"
|
||||||
|
>;
|
||||||
type SkillReferenceTarget = Pick<CompanySkill, "id" | "key" | "slug">;
|
type SkillReferenceTarget = Pick<CompanySkill, "id" | "key" | "slug">;
|
||||||
type SkillSourceInfoTarget = Pick<
|
type SkillSourceInfoTarget = Pick<
|
||||||
CompanySkill,
|
CompanySkill,
|
||||||
|
|
@ -147,6 +151,27 @@ type RuntimeSkillEntryOptions = {
|
||||||
|
|
||||||
const skillInventoryRefreshPromises = new Map<string, Promise<void>>();
|
const skillInventoryRefreshPromises = new Map<string, Promise<void>>();
|
||||||
|
|
||||||
|
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 = [
|
const PROJECT_SCAN_DIRECTORY_ROOTS = [
|
||||||
"skills",
|
"skills",
|
||||||
"skills/.curated",
|
"skills/.curated",
|
||||||
|
|
@ -1523,7 +1548,6 @@ function toCompanySkillListItem(skill: CompanySkillListRow, attachedAgentCount:
|
||||||
export function companySkillService(db: Db) {
|
export function companySkillService(db: Db) {
|
||||||
const agents = agentService(db);
|
const agents = agentService(db);
|
||||||
const projects = projectService(db);
|
const projects = projectService(db);
|
||||||
const secretsSvc = secretService(db);
|
|
||||||
|
|
||||||
async function ensureBundledSkills(companyId: string) {
|
async function ensureBundledSkills(companyId: string) {
|
||||||
for (const skillsRoot of resolveBundledSkillsRoot()) {
|
for (const skillsRoot of resolveBundledSkillsRoot()) {
|
||||||
|
|
@ -1553,10 +1577,19 @@ export function companySkillService(db: Db) {
|
||||||
|
|
||||||
async function pruneMissingLocalPathSkills(companyId: string) {
|
async function pruneMissingLocalPathSkills(companyId: string) {
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select()
|
.select({
|
||||||
|
id: companySkills.id,
|
||||||
|
key: companySkills.key,
|
||||||
|
slug: companySkills.slug,
|
||||||
|
sourceType: companySkills.sourceType,
|
||||||
|
sourceLocator: companySkills.sourceLocator,
|
||||||
|
})
|
||||||
.from(companySkills)
|
.from(companySkills)
|
||||||
.where(eq(companySkills.companyId, companyId));
|
.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));
|
const missingIds = new Set(await findMissingLocalSkillIds(skills));
|
||||||
if (missingIds.size === 0) return;
|
if (missingIds.size === 0) return;
|
||||||
|
|
||||||
|
|
@ -1628,25 +1661,37 @@ export function companySkillService(db: Db) {
|
||||||
async function listFull(companyId: string): Promise<CompanySkill[]> {
|
async function listFull(companyId: string): Promise<CompanySkill[]> {
|
||||||
await ensureSkillInventoryCurrent(companyId);
|
await ensureSkillInventoryCurrent(companyId);
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select()
|
.select(selectCompanySkillColumns())
|
||||||
.from(companySkills)
|
.from(companySkills)
|
||||||
.where(eq(companySkills.companyId, companyId))
|
.where(eq(companySkills.companyId, companyId))
|
||||||
.orderBy(asc(companySkills.name), asc(companySkills.key));
|
.orderBy(asc(companySkills.name), asc(companySkills.key));
|
||||||
return rows.map((row) => toCompanySkill(row));
|
return rows.map((row) => toCompanySkill(row));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getById(id: string) {
|
async function listReferenceTargets(companyId: string): Promise<SkillReferenceTarget[]> {
|
||||||
const row = await db
|
const rows = await db
|
||||||
.select()
|
.select({
|
||||||
|
id: companySkills.id,
|
||||||
|
key: companySkills.key,
|
||||||
|
slug: companySkills.slug,
|
||||||
|
})
|
||||||
.from(companySkills)
|
.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);
|
.then((rows) => rows[0] ?? null);
|
||||||
return row ? toCompanySkill(row) : null;
|
return row ? toCompanySkill(row) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getByKey(companyId: string, key: string) {
|
async function getByKey(companyId: string, key: string) {
|
||||||
const row = await db
|
const row = await db
|
||||||
.select()
|
.select(selectCompanySkillColumns())
|
||||||
.from(companySkills)
|
.from(companySkills)
|
||||||
.where(and(eq(companySkills.companyId, companyId), eq(companySkills.key, key)))
|
.where(and(eq(companySkills.companyId, companyId), eq(companySkills.key, key)))
|
||||||
.then((rows) => rows[0] ?? null);
|
.then((rows) => rows[0] ?? null);
|
||||||
|
|
@ -1654,67 +1699,36 @@ export function companySkillService(db: Db) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function usage(companyId: string, key: string): Promise<CompanySkillUsageAgent[]> {
|
async function usage(companyId: string, key: string): Promise<CompanySkillUsageAgent[]> {
|
||||||
const skills = await listFull(companyId);
|
const skills = await listReferenceTargets(companyId);
|
||||||
const agentRows = await agents.list(companyId);
|
const agentRows = await agents.list(companyId);
|
||||||
const desiredAgents = agentRows.filter((agent) => {
|
const desiredAgents = agentRows.filter((agent) => {
|
||||||
const desiredSkills = resolveDesiredSkillKeys(skills, agent.adapterConfig as Record<string, unknown>);
|
const desiredSkills = resolveDesiredSkillKeys(skills, agent.adapterConfig as Record<string, unknown>);
|
||||||
return desiredSkills.includes(key);
|
return desiredSkills.includes(key);
|
||||||
});
|
});
|
||||||
|
|
||||||
return Promise.all(
|
return desiredAgents.map((agent) => ({
|
||||||
desiredAgents.map(async (agent) => {
|
id: agent.id,
|
||||||
const adapter = findActiveServerAdapter(agent.adapterType);
|
name: agent.name,
|
||||||
let actualState: string | null = null;
|
urlKey: agent.urlKey,
|
||||||
|
adapterType: agent.adapterType,
|
||||||
if (!adapter?.listSkills) {
|
desired: true,
|
||||||
actualState = "unsupported";
|
// Runtime adapter state is intentionally omitted from this bounded metadata read.
|
||||||
} else {
|
actualState: null,
|
||||||
try {
|
}));
|
||||||
const { config: runtimeConfig } = await secretsSvc.resolveAdapterConfigForRuntime(
|
|
||||||
agent.companyId,
|
|
||||||
agent.adapterConfig as Record<string, unknown>,
|
|
||||||
);
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function detail(companyId: string, id: string): Promise<CompanySkillDetail | null> {
|
async function detail(companyId: string, id: string): Promise<CompanySkillDetail | null> {
|
||||||
await ensureSkillInventoryCurrent(companyId);
|
await ensureSkillInventoryCurrent(companyId);
|
||||||
const skill = await getById(id);
|
const skill = await getById(companyId, id);
|
||||||
if (!skill || skill.companyId !== companyId) return null;
|
if (!skill) return null;
|
||||||
const usedByAgents = await usage(companyId, skill.key);
|
const usedByAgents = await usage(companyId, skill.key);
|
||||||
return enrichSkill(skill, usedByAgents.length, usedByAgents);
|
return enrichSkill(skill, usedByAgents.length, usedByAgents);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateStatus(companyId: string, skillId: string): Promise<CompanySkillUpdateStatus | null> {
|
async function updateStatus(companyId: string, skillId: string): Promise<CompanySkillUpdateStatus | null> {
|
||||||
await ensureSkillInventoryCurrent(companyId);
|
await ensureSkillInventoryCurrent(companyId);
|
||||||
const skill = await getById(skillId);
|
const skill = await getById(companyId, skillId);
|
||||||
if (!skill || skill.companyId !== companyId) return null;
|
if (!skill) return null;
|
||||||
|
|
||||||
if (skill.sourceType !== "github" && skill.sourceType !== "skills_sh") {
|
if (skill.sourceType !== "github" && skill.sourceType !== "skills_sh") {
|
||||||
return {
|
return {
|
||||||
|
|
@ -1757,8 +1771,8 @@ export function companySkillService(db: Db) {
|
||||||
|
|
||||||
async function readFile(companyId: string, skillId: string, relativePath: string): Promise<CompanySkillFileDetail | null> {
|
async function readFile(companyId: string, skillId: string, relativePath: string): Promise<CompanySkillFileDetail | null> {
|
||||||
await ensureSkillInventoryCurrent(companyId);
|
await ensureSkillInventoryCurrent(companyId);
|
||||||
const skill = await getById(skillId);
|
const skill = await getById(companyId, skillId);
|
||||||
if (!skill || skill.companyId !== companyId) return null;
|
if (!skill) return null;
|
||||||
|
|
||||||
const normalizedPath = normalizePortablePath(relativePath || "SKILL.md");
|
const normalizedPath = normalizePortablePath(relativePath || "SKILL.md");
|
||||||
const fileEntry = skill.fileInventory.find((entry) => entry.path === normalizedPath);
|
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<CompanySkillFileDetail> {
|
async function updateFile(companyId: string, skillId: string, relativePath: string, content: string): Promise<CompanySkillFileDetail> {
|
||||||
await ensureSkillInventoryCurrent(companyId);
|
await ensureSkillInventoryCurrent(companyId);
|
||||||
const skill = await getById(skillId);
|
const skill = await getById(companyId, skillId);
|
||||||
if (!skill || skill.companyId !== companyId) throw notFound("Skill not found");
|
if (!skill) throw notFound("Skill not found");
|
||||||
|
|
||||||
const source = deriveSkillSourceInfo(skill);
|
const source = deriveSkillSourceInfo(skill);
|
||||||
if (!source.editable || skill.sourceType !== "local_path") {
|
if (!source.editable || skill.sourceType !== "local_path") {
|
||||||
|
|
@ -1895,8 +1909,8 @@ export function companySkillService(db: Db) {
|
||||||
|
|
||||||
async function installUpdate(companyId: string, skillId: string): Promise<CompanySkill | null> {
|
async function installUpdate(companyId: string, skillId: string): Promise<CompanySkill | null> {
|
||||||
await ensureSkillInventoryCurrent(companyId);
|
await ensureSkillInventoryCurrent(companyId);
|
||||||
const skill = await getById(skillId);
|
const skill = await getById(companyId, skillId);
|
||||||
if (!skill || skill.companyId !== companyId) return null;
|
if (!skill) return null;
|
||||||
|
|
||||||
const status = await updateStatus(companyId, skillId);
|
const status = await updateStatus(companyId, skillId);
|
||||||
if (!status?.supported) {
|
if (!status?.supported) {
|
||||||
|
|
@ -2136,7 +2150,7 @@ export function companySkillService(db: Db) {
|
||||||
return skillDir;
|
return skillDir;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveRuntimeSkillMaterializedPath(companyId: string, skill: CompanySkill) {
|
function resolveRuntimeSkillMaterializedPath(companyId: string, skill: Pick<CompanySkill, "key" | "slug">) {
|
||||||
const runtimeRoot = path.resolve(resolveManagedSkillsRoot(companyId), "__runtime__");
|
const runtimeRoot = path.resolve(resolveManagedSkillsRoot(companyId), "__runtime__");
|
||||||
return path.resolve(runtimeRoot, buildSkillRuntimeName(skill.key, skill.slug));
|
return path.resolve(runtimeRoot, buildSkillRuntimeName(skill.key, skill.slug));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue