mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-30 21:49:18 +01:00
feat(skills): let agents update company skills safely (#15049)
## Thinking Path > - Paperclip is an open source control plane for AI-agent companies. > - Company skills give agents reusable work instructions. > - Skill Studio can edit skill files and save version history. > - Agents can create a skill, but they do not have a first-class update tool. > - An agent update needs a version check and safe retry behavior to prevent lost edits. > - This pull request adds `update_skill` through the existing company skill file API. > - The change keeps company policy, version history, and audit records in one path. ## Linked Issues or Issue Description **Subsystem affected** Cross-cutting: the server API, shared validation, and runner tool catalog. **Problem or motivation** An agent can create a company skill but cannot update its `SKILL.md` through a first-class tool. An unguarded retry can also create duplicate versions or overwrite a newer edit. **Proposed solution** Add `update_skill` with a required current version ID and a retry key. Route it through the existing skill file API. Reject stale versions and changed-input retries. Save the version and audit event together. **Alternatives considered** A separate write endpoint would duplicate the Skill Studio mutation path and policy checks. This PR reuses that path instead. **Roadmap alignment** This work extends Skills Manager and Skill Studio, which are listed in `ROADMAP.md`. **Additional context** The tool accepts a complete `SKILL.md`, not a partial patch. Callers must read the current version before they edit it. ## What Changed - Add optional version and retry fields to the existing skill file update contract. - Add a guarded API update with a stable retry receipt and attributed audit event. - Add `update_skill` to native and semantic runner tool catalogs, with mode and policy gates. - Add unit, integration, protocol, and semantic-tool regression coverage. - Document agent use and extend the OpenAPI request contract. ## Verification - Focused tests and direct server and runner TypeScript checks passed before this PR. - `git diff --check` passed after the rebase onto `master`. - CI passed on the latest PR head, including the full test matrix, typecheck, and build. Local full typecheck and build stopped because `cargo` is not installed. The local full test run ended without a verdict. - No dedicated end-to-end eval scenario was added or run. The protocol coverage and semantic-tool test cover the new action deterministically. - Reviewers can read a skill version, call `update_skill`, repeat the same key, then try a stale version and a changed-input key. Only the first edit must create a new version. ## Risks - File writes and database transactions must stay in sync when a write fails. The integration tests cover failed writes and retry behavior, but CI must verify them on the PR head. - Existing Skill Studio callers do not send the new optional coordination fields. Their request shape remains valid. ## Model Used - OpenAI Codex CLI assisted with this change. The runner did not expose the exact model ID or context window. The agent used code execution and repository tools. ## 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; exact model ID and context window were not exposed) - [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 not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass (focused tests; full suite is pending CI) - [x] I have added or updated tests where applicable - [x] 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 (53 pass, 4 skip on the latest head) - [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>
This commit is contained in:
1 parent
b17019e14d
commit
eb049aebf2
32 files changed
+481
-72
No files matched your search
@@ -7,7 +7,7 @@ import { once } from "node:events";
|
||||
import type { SkillSourceDiscoveryEvent } from "@paperclipai/shared";
|
||||
import { createHash } from "node:crypto";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { activityLog } from "@paperclipai/db";
|
||||
import { activityLog, companySkills } from "@paperclipai/db";
|
||||
import { persistActivity, publishActivity } from "../services/activity-log.js";
|
||||
import { projectToolContext } from "../services/project-tool-context.js";
|
||||
import { Router, type Request } from "express";
|
||||
@@ -1219,13 +1219,66 @@ export function companySkillRoutes(db: Db) {
|
||||
const companyId = req.params.companyId as string;
|
||||
const skillId = req.params.skillId as string;
|
||||
await assertCanMutateCompanySkills(req, companyId, "skills.edit", () => skillPolicyResource({ companyId, skillId }));
|
||||
const { idempotencyKey, expectedVersionId, ...input } = req.body;
|
||||
if (idempotencyKey) {
|
||||
const actor = getActorInfo(req);
|
||||
const runContext = req.actor.type === "agent" && req.actor.source === "agent_jwt" && req.actor.runId
|
||||
? await projectToolContext(db, req.actor, true, "Skill") : null;
|
||||
const receiptKey = `skill-file:${companyId}:${actor.actorId}:${runContext?.issue.id ?? "board"}:${skillId}:${idempotencyKey}`;
|
||||
const fingerprint = createHash("sha256").update(JSON.stringify({ ...input, expectedVersionId })).digest("hex");
|
||||
const rollbackState: { current: { restore: () => Promise<void>; versionId: string | null } | null } = { current: null };
|
||||
const outcome = await db.transaction(async tx => {
|
||||
await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${receiptKey}, 0))`);
|
||||
if (runContext) await projectToolContext(tx as unknown as Db, req.actor, true, "Skill");
|
||||
const [prior] = await tx.select().from(activityLog).where(and(
|
||||
eq(activityLog.companyId, companyId), eq(activityLog.action, "company.skill_file_updated"),
|
||||
sql`${activityLog.details}->>'idempotencyKey' = ${receiptKey}`,
|
||||
));
|
||||
if (prior) {
|
||||
if (prior.details?.fingerprint !== fingerprint) throw conflict("Skill file idempotency key was used with different inputs");
|
||||
return { receipt: prior.details?.receipt, publication: null };
|
||||
}
|
||||
let publication: ActivityPublication | null = null;
|
||||
let receipt: { skillId: string; path: string; versionId: string | null; studioPath: string } | null = null;
|
||||
await companySkillService(tx as unknown as Db).updateFile(companyId, skillId, input.path, input.content, skillActor(req), {
|
||||
encoding: input.encoding, executable: input.executable, expectedVersionId,
|
||||
onRollback: (restore, versionId) => { rollbackState.current = { restore, versionId }; },
|
||||
afterUpdate: async (versionId) => {
|
||||
receipt = { skillId, path: input.path, versionId, studioPath: `/skills/studio/${encodeURIComponent(skillId)}` };
|
||||
const activity = await persistActivity(tx as unknown as Db, {
|
||||
companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId,
|
||||
runId: actor.runId, agentApiKeyId: actor.agentApiKeyId, issueId: runContext?.issue.id,
|
||||
action: "company.skill_file_updated", entityType: "company_skill", entityId: skillId,
|
||||
details: { path: input.path, markdown: input.path === "SKILL.md", versionId,
|
||||
sourceIssueId: runContext?.issue.id ?? null, idempotencyKey: receiptKey, fingerprint, receipt },
|
||||
});
|
||||
publication = activity.publication;
|
||||
},
|
||||
});
|
||||
return { receipt, publication };
|
||||
}).catch(async (error) => {
|
||||
if (rollbackState.current) {
|
||||
const { restore, versionId } = rollbackState.current;
|
||||
await db.transaction(async tx => {
|
||||
await tx.execute(sql`select ${companySkills.id} from ${companySkills} where ${companySkills.id} = ${skillId} and ${companySkills.companyId} = ${companyId} for update`);
|
||||
const [current] = await tx.select({ versionId: companySkills.currentVersionId }).from(companySkills)
|
||||
.where(and(eq(companySkills.id, skillId), eq(companySkills.companyId, companyId)));
|
||||
if (current && current.versionId === versionId) await restore();
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
if (outcome.publication) publishActivity(outcome.publication);
|
||||
res.json(outcome.receipt);
|
||||
return;
|
||||
}
|
||||
const result = await svc.updateFile(
|
||||
companyId,
|
||||
skillId,
|
||||
String(req.body.path ?? ""),
|
||||
String(req.body.content ?? ""),
|
||||
skillActor(req),
|
||||
{ encoding: req.body.encoding, executable: req.body.executable },
|
||||
{ encoding: req.body.encoding, executable: req.body.executable, expectedVersionId },
|
||||
);
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
|
||||
@@ -7740,12 +7740,12 @@ registry.registerPath({
|
||||
method: "patch",
|
||||
path: "/api/companies/{companyId}/skills/{skillId}/files",
|
||||
tags: ["skills"],
|
||||
summary: "Update a skill file",
|
||||
summary: "Update a skill file (optional expectedVersionId and idempotencyKey guard agent retries)",
|
||||
request: {
|
||||
params: z.object({ companyId: z.string(), skillId: z.string() }),
|
||||
body: jsonBody(companySkillFileUpdateSchema),
|
||||
},
|
||||
responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized },
|
||||
responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 409: r.conflict },
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
|
||||
@@ -4643,6 +4643,11 @@ export function companySkillService(db: Db) {
|
||||
for (const slug of [...new Set([initial.slug, ...additionalSlugs])].filter(Boolean).sort()) {
|
||||
await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`${companyId}:${slug}`}, 0))`);
|
||||
}
|
||||
await tx.execute(sql`
|
||||
select ${companySkills.id} from ${companySkills}
|
||||
where ${companySkills.id} = ${skillId} and ${companySkills.companyId} = ${companyId}
|
||||
for update
|
||||
`);
|
||||
const skill = await getById(companyId, skillId, tx);
|
||||
if (!skill) throw notFound("Skill not found");
|
||||
if (skill.slug !== initial.slug) throw conflict("Skill was renamed. Retry the operation.");
|
||||
@@ -4669,10 +4674,16 @@ export function companySkillService(db: Db) {
|
||||
relativePath: string,
|
||||
content: string,
|
||||
actor: SkillActor | null = null,
|
||||
format: { encoding?: "utf8" | "base64"; executable?: boolean } = {},
|
||||
format: { encoding?: "utf8" | "base64"; executable?: boolean; expectedVersionId?: string | null;
|
||||
afterUpdate?: (versionId: string | null) => Promise<void>;
|
||||
onRollback?: (restore: () => Promise<void>, versionId: string | null) => void } = {},
|
||||
): Promise<CompanySkillFileDetail> {
|
||||
return withSkillFileMutation(companyId, skillId, async (skill, tx) => {
|
||||
|
||||
if (format.expectedVersionId !== undefined && skill.currentVersionId !== format.expectedVersionId) {
|
||||
throw conflict("Skill version changed. Read the current version before retrying.");
|
||||
}
|
||||
|
||||
const source = deriveSkillSourceInfo(skill);
|
||||
if (!source.editable || skill.sourceType !== "local_path") {
|
||||
throw unprocessable(source.editableReason ?? "This skill cannot be edited.");
|
||||
@@ -4689,41 +4700,55 @@ export function companySkillService(db: Db) {
|
||||
}
|
||||
const previousContent = await fs.readFile(absolutePath).catch(() => null);
|
||||
const previousMode = (await fs.stat(absolutePath).catch(() => null))?.mode ?? 0o644;
|
||||
const restore = async () => {
|
||||
if (previousContent === null) await fs.rm(absolutePath, { force: true });
|
||||
else {
|
||||
await fs.writeFile(absolutePath, previousContent);
|
||||
await fs.chmod(absolutePath, previousMode);
|
||||
}
|
||||
};
|
||||
format.onRollback?.(restore, skill.currentVersionId);
|
||||
const mode = (format.executable ?? Boolean(previousMode & 0o111)) ? 0o755 : 0o644;
|
||||
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
|
||||
await fs.writeFile(absolutePath, bytes);
|
||||
await fs.chmod(absolutePath, mode);
|
||||
try {
|
||||
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
|
||||
await fs.writeFile(absolutePath, bytes);
|
||||
await fs.chmod(absolutePath, mode);
|
||||
|
||||
if (normalizedPath === "SKILL.md") {
|
||||
const parsed = parseFrontmatterMarkdown(content);
|
||||
await tx
|
||||
.update(companySkills)
|
||||
.set({
|
||||
name: asString(parsed.frontmatter.name) ?? skill.name,
|
||||
description: asString(parsed.frontmatter.description) ?? skill.description,
|
||||
markdown: content,
|
||||
...readSkillStoreMetadata(parsed.frontmatter, skill.metadata),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(companySkills.id, skill.id));
|
||||
} else {
|
||||
await tx
|
||||
.update(companySkills)
|
||||
.set({ updatedAt: new Date() })
|
||||
.where(eq(companySkills.id, skill.id));
|
||||
if (normalizedPath === "SKILL.md") {
|
||||
const parsed = parseFrontmatterMarkdown(content);
|
||||
await tx
|
||||
.update(companySkills)
|
||||
.set({
|
||||
name: asString(parsed.frontmatter.name) ?? skill.name,
|
||||
description: asString(parsed.frontmatter.description) ?? skill.description,
|
||||
markdown: content,
|
||||
...readSkillStoreMetadata(parsed.frontmatter, skill.metadata),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(companySkills.id, skill.id));
|
||||
} else {
|
||||
await tx
|
||||
.update(companySkills)
|
||||
.set({ updatedAt: new Date() })
|
||||
.where(eq(companySkills.id, skill.id));
|
||||
}
|
||||
|
||||
const inventory = await refreshEditedSkillInventory(skill, tx);
|
||||
if (!previousContent?.equals(bytes) || Boolean(previousMode & 0o111) !== Boolean(mode & 0o111)) {
|
||||
await createVersion(companyId, skillId, {}, actor, {
|
||||
database: tx, skipInventoryRefresh: true, skill, fileInventory: inventory,
|
||||
});
|
||||
}
|
||||
|
||||
const updated = await getById(companyId, skillId, tx);
|
||||
const detail = updated ? await readLoadedSkillFile(updated, normalizedPath) : null;
|
||||
if (!detail) throw notFound("Skill file not found");
|
||||
await format.afterUpdate?.(updated?.currentVersionId ?? null);
|
||||
return detail;
|
||||
} catch (error) {
|
||||
await restore();
|
||||
throw error;
|
||||
}
|
||||
|
||||
const inventory = await refreshEditedSkillInventory(skill, tx);
|
||||
if (!previousContent?.equals(bytes) || Boolean(previousMode & 0o111) !== Boolean(mode & 0o111)) {
|
||||
await createVersion(companyId, skillId, {}, actor, {
|
||||
database: tx, skipInventoryRefresh: true, skill, fileInventory: inventory,
|
||||
});
|
||||
}
|
||||
|
||||
const updated = await getById(companyId, skillId, tx);
|
||||
const detail = updated ? await readLoadedSkillFile(updated, normalizedPath) : null;
|
||||
if (!detail) throw notFound("Skill file not found");
|
||||
return detail;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -102,7 +102,7 @@ describe("PaperclipRunnerToolAuthority", () => {
|
||||
issueId,
|
||||
runId,
|
||||
});
|
||||
expect(authority.definitions()).toHaveLength(35);
|
||||
expect(authority.definitions()).toHaveLength(36);
|
||||
const questions = authority.definitions().find(tool => tool.name === "request_human_input")!;
|
||||
expect(questions.description).toContain("ask only the next unanswered question");
|
||||
expect(questions.description).toContain("Never fabricate answers");
|
||||
@@ -131,6 +131,7 @@ describe("PaperclipRunnerToolAuthority", () => {
|
||||
"list_document_revisions",
|
||||
"write_document",
|
||||
"create_skill",
|
||||
"update_skill",
|
||||
"list_agents",
|
||||
"get_agent",
|
||||
"list_approvals",
|
||||
|
||||
@@ -13,7 +13,7 @@ import { resolveCoreTrustPreset } from "../trust-preset-resolver.js";
|
||||
import { normalizeIssueExecutionPolicy } from "../issue-execution-policy.js";
|
||||
import { buildLowTrustSourceTrust } from "../source-trust.js";
|
||||
import { handoffPlanContext } from "./handoff-plan-context.js";
|
||||
import { callCreateSkillTool } from "../skill-tools.js";
|
||||
import { callCreateSkillTool, callUpdateSkillTool } from "../skill-tools.js";
|
||||
import { callProjectTool } from "../project-tools.js";
|
||||
import { isConnectorTool, executeConnectorTool, type ConnectorAssignment } from "../connector-runtime.js";
|
||||
import { resolveNativeRuntimeMcpSnapshot } from "./runtime-context.js";
|
||||
@@ -97,7 +97,7 @@ const IMPLEMENTED_OPERATIONS = new Set([
|
||||
"search_api", "call_api", "hire_agent",
|
||||
"get_task_context", "get_task_history", "search_tasks", "report_progress", "set_task_title",
|
||||
"request_human_input",
|
||||
"create_skill", "create_task", "reassign_task", "set_dependencies", "create_project", "list_project_repositories", "list_projects", "register_deliverable",
|
||||
"create_skill", "update_skill", "create_task", "reassign_task", "set_dependencies", "create_project", "list_project_repositories", "list_projects", "register_deliverable",
|
||||
"list_documents", "read_document", "list_document_revisions", "write_document",
|
||||
"list_agents", "get_agent", "list_approvals", "get_approval", "get_approval_context",
|
||||
]);
|
||||
@@ -414,6 +414,12 @@ export class PaperclipRunnerToolAuthority {
|
||||
if (!apiUrl || !token) throw new Error("Skill tool authentication is unavailable");
|
||||
return callCreateSkillTool({ arguments: input, apiUrl, token, companyId: this.binding.companyId });
|
||||
}
|
||||
case "update_skill": {
|
||||
const apiUrl = this.binding.apiUrl ?? process.env.PAPERCLIP_API_URL;
|
||||
const token = createLocalAgentJwt(this.binding.agentId, this.binding.companyId, context.actor.adapterType, this.binding.runId, context.run.responsibleUserId);
|
||||
if (!apiUrl || !token) throw new Error("Skill tool authentication is unavailable");
|
||||
return callUpdateSkillTool({ arguments: input, apiUrl, token, companyId: this.binding.companyId });
|
||||
}
|
||||
case "create_project":
|
||||
case "list_project_repositories":
|
||||
case "list_projects": {
|
||||
|
||||
@@ -48,6 +48,7 @@ function dedicatedTools(method: string, path: string): string[] {
|
||||
if (/\/agents\/\{[^}]+\}\/instructions-bundle\/revision\/\{[^}]+\}$/.test(path) && method === "GET") return ["read_agent_instructions"];
|
||||
if (/\/agents\/\{[^}]+\}\/instructions-bundle\/restore$/.test(path) && method === "POST") return ["restore_agent_instructions"];
|
||||
if (/\/companies\/\{[^}]+\}\/skills$/.test(path) && method === "POST") return ["create_skill"];
|
||||
if (/\/companies\/\{[^}]+\}\/skills\/\{[^}]+\}\/files$/.test(path) && method === "PATCH") return ["update_skill"];
|
||||
if (/\/companies\/\{[^}]+\}\/agent-hires$/.test(path) && method === "POST") return ["hire_agent"];
|
||||
if (/\/projects$/.test(path)) return method === "GET" ? ["list_projects"] : method === "POST" ? ["create_project"] : [];
|
||||
if (/\/project-repositories$/.test(path) && method === "GET") return ["list_project_repositories"];
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { activityLog, companyMemberships, heartbeatRuns, issues } from "@paperclipai/db";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { startRunnerApiTestServer } from "../../__tests__/helpers/runner-api-server.js";
|
||||
import { PaperclipRunnerToolAuthority } from "./paperclip-runner-tool-authority.js";
|
||||
import { companySkillPolicyService } from "../company-skill-policy.js";
|
||||
import { companySkillService } from "../company-skills.js";
|
||||
|
||||
describe("runner update_skill through the real skill API", () => {
|
||||
let server: Awaited<ReturnType<typeof startRunnerApiTestServer>>;
|
||||
let home: string;
|
||||
const previousSecret = process.env.PAPERCLIP_AGENT_JWT_SECRET;
|
||||
const previousHome = process.env.PAPERCLIP_HOME;
|
||||
beforeAll(async () => {
|
||||
process.env.PAPERCLIP_AGENT_JWT_SECRET = randomUUID();
|
||||
home = await mkdtemp(join(tmpdir(), "paperclip-update-skill-"));
|
||||
process.env.PAPERCLIP_HOME = home;
|
||||
server = await startRunnerApiTestServer();
|
||||
}, 60_000);
|
||||
afterAll(async () => {
|
||||
await server?.close();
|
||||
if (previousSecret === undefined) delete process.env.PAPERCLIP_AGENT_JWT_SECRET;
|
||||
else process.env.PAPERCLIP_AGENT_JWT_SECRET = previousSecret;
|
||||
if (previousHome === undefined) delete process.env.PAPERCLIP_HOME;
|
||||
else process.env.PAPERCLIP_HOME = previousHome;
|
||||
if (home) await rm(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function fixture(options: Parameters<typeof server.fixture>[0] = {}) {
|
||||
const result = await server.fixture(options);
|
||||
await server.db.insert(companyMemberships).values({ companyId: result.companyId, principalType: "agent", principalId: result.agentId, status: "active", membershipRole: "member" });
|
||||
const skill = await companySkillService(server.db).createLocalSkill(result.companyId, {
|
||||
name: "release-review", description: "Review releases.",
|
||||
markdown: "---\nname: release-review\ndescription: Review releases.\n---\n\n# Review\nInspect the release.\n",
|
||||
});
|
||||
const args = { skillId: skill.id, expectedVersionId: skill.currentVersionId, idempotencyKey: randomUUID(),
|
||||
markdown: "---\nname: release-review\ndescription: Review releases.\n---\n\n# Review\nInspect the release and its tests.\n" };
|
||||
return { ...result, skill, args };
|
||||
}
|
||||
|
||||
it("persists exactly one version and attributed activity across retries and a replacement run", async () => {
|
||||
const testFixture = await fixture();
|
||||
const { authority, args, companyId, skill, issueId } = testFixture;
|
||||
expect(authority.definitions().some(tool => tool.name === "update_skill")).toBe(true);
|
||||
const first = await authority.execute({ tool: "update_skill", callId: "first", arguments: args });
|
||||
const second = await authority.execute({ tool: "update_skill", callId: "retry", arguments: args });
|
||||
expect(second).toEqual(first);
|
||||
expect(first).toMatchObject({ skillId: skill.id, path: "SKILL.md", versionId: expect.any(String) });
|
||||
const replacementRunId = randomUUID();
|
||||
await server.db.update(heartbeatRuns).set({ status: "failed" }).where(eq(heartbeatRuns.id, testFixture.runId));
|
||||
await server.db.insert(heartbeatRuns).values({ id: replacementRunId, companyId, agentId: testFixture.agentId,
|
||||
status: "running", runtimeMode: "native", nativeIssueId: issueId, invocationSource: "assignment", triggerDetail: "system", contextSnapshot: { issueId } });
|
||||
await server.db.update(issues).set({ executionRunId: replacementRunId }).where(eq(issues.id, issueId));
|
||||
const replacement = new PaperclipRunnerToolAuthority(server.db, { ...testFixture, runId: replacementRunId });
|
||||
expect(await replacement.execute({ tool: "update_skill", callId: "replacement", arguments: args })).toEqual(first);
|
||||
expect(await companySkillService(server.db).listVersions(companyId, skill.id)).toHaveLength(2);
|
||||
expect((await server.db.select().from(activityLog).where(eq(activityLog.action, "company.skill_file_updated")))
|
||||
.filter(event => event.entityId === skill.id))
|
||||
.toEqual([expect.objectContaining({ entityId: skill.id, details: expect.objectContaining({ sourceIssueId: issueId, versionId: (first as { versionId: string }).versionId }) })]);
|
||||
await expect(replacement.execute({ tool: "update_skill", callId: "changed-key", arguments: { ...args, markdown: args.markdown + "More." } })).rejects.toThrow(/different inputs/);
|
||||
await expect(replacement.execute({ tool: "update_skill", callId: "stale", arguments: { ...args, idempotencyKey: randomUUID() } })).rejects.toThrow(/version changed/);
|
||||
});
|
||||
|
||||
it("denies policy changes and cross-company skill IDs", async () => {
|
||||
const first = await fixture();
|
||||
const second = await fixture();
|
||||
await expect(second.authority.execute({ tool: "update_skill", callId: "foreign", arguments: { ...first.args, idempotencyKey: randomUUID() } })).rejects.toThrow();
|
||||
await companySkillPolicyService(server.db).replace({ companyId: first.companyId, expectedRevision: 0,
|
||||
policy: { schemaVersion: 1, defaultEffect: "deny", rules: [] }, activity: { actorType: "user", actorId: "test-board" } });
|
||||
await expect(first.authority.execute({ tool: "update_skill", callId: "denied", arguments: first.args })).rejects.toThrow(/company policy/);
|
||||
expect(await companySkillService(server.db).listVersions(first.companyId, first.skill.id)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("serializes simultaneous retries and checks policy again after a successful edit", async () => {
|
||||
const testFixture = await fixture();
|
||||
const calls = await Promise.all(["parallel-a", "parallel-b"].map(callId =>
|
||||
testFixture.authority.execute({ tool: "update_skill", callId, arguments: testFixture.args })));
|
||||
expect(calls[0]).toEqual(calls[1]);
|
||||
expect(await companySkillService(server.db).listVersions(testFixture.companyId, testFixture.skill.id)).toHaveLength(2);
|
||||
await companySkillPolicyService(server.db).replace({ companyId: testFixture.companyId, expectedRevision: 0,
|
||||
policy: { schemaVersion: 1, defaultEffect: "deny", rules: [] }, activity: { actorType: "user", actorId: "test-board" } });
|
||||
await expect(testFixture.authority.execute({ tool: "update_skill", callId: "revoked-retry", arguments: testFixture.args })).rejects.toThrow(/company policy/);
|
||||
expect(await companySkillService(server.db).listVersions(testFixture.companyId, testFixture.skill.id)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("restores the file when the required audit callback fails", async () => {
|
||||
const testFixture = await fixture();
|
||||
const service = companySkillService(server.db);
|
||||
const original = await service.readFile(testFixture.companyId, testFixture.skill.id, "SKILL.md");
|
||||
await expect(service.updateFile(testFixture.companyId, testFixture.skill.id, "SKILL.md", testFixture.args.markdown,
|
||||
null, { expectedVersionId: testFixture.skill.currentVersionId, afterUpdate: async () => { throw new Error("audit unavailable"); } }))
|
||||
.rejects.toThrow("audit unavailable");
|
||||
expect((await service.readFile(testFixture.companyId, testFixture.skill.id, "SKILL.md"))?.content).toBe(original?.content);
|
||||
expect(await service.listVersions(testFixture.companyId, testFixture.skill.id)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("rejects a competing edit with a different key and the same expected version", async () => {
|
||||
const testFixture = await fixture();
|
||||
const attempts = await Promise.allSettled([testFixture.args, { ...testFixture.args,
|
||||
idempotencyKey: randomUUID(), markdown: testFixture.args.markdown + "Another edit." }].map((argumentsValue, index) =>
|
||||
testFixture.authority.execute({ tool: "update_skill", callId: `race-${index}`, arguments: argumentsValue })));
|
||||
expect(attempts.filter(attempt => attempt.status === "fulfilled")).toHaveLength(1);
|
||||
expect(attempts.filter(attempt => attempt.status === "rejected")).toHaveLength(1);
|
||||
expect(await companySkillService(server.db).listVersions(testFixture.companyId, testFixture.skill.id)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("hides the tool and rejects invocation in planning mode", async () => {
|
||||
const testFixture = await fixture({ mode: "planning" });
|
||||
expect(new PaperclipRunnerToolAuthority(server.db, { ...testFixture, workMode: "planning" }).definitions().some(tool => tool.name === "update_skill")).toBe(false);
|
||||
await expect(testFixture.authority.execute({ tool: "update_skill", callId: "planning", arguments: testFixture.args })).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { callCreateSkillTool, createSkillToolInput } from "./skill-tools.js";
|
||||
import { callCreateSkillTool, createSkillToolInput, callUpdateSkillTool, updateSkillToolInput } from "./skill-tools.js";
|
||||
|
||||
const input = {
|
||||
name: "release-review", description: "Review release notes.", idempotencyKey: "release-review-1",
|
||||
@@ -36,3 +36,29 @@ describe("create skill tool", () => {
|
||||
expect(result).toEqual({ id: "skill-1", name: input.name, slug: input.name, description: input.description, versionId: "version-1", studioPath: "/skills/studio/skill-1" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("update skill tool", () => {
|
||||
const args = { skillId: "00000000-0000-4000-8000-000000000001", expectedVersionId: "00000000-0000-4000-8000-000000000002",
|
||||
markdown: input.markdown, idempotencyKey: "update-1" };
|
||||
|
||||
it("rejects incomplete content and untrusted scope without a request", async () => {
|
||||
const fetcher = vi.fn();
|
||||
expect(updateSkillToolInput.parse(args)).toEqual(args);
|
||||
for (const invalid of [{ markdown: "# Missing frontmatter" }, { markdown: input.markdown.replace("# Review\nCheck each release note against the change.", " ") },
|
||||
{ companyId: "other" }, { expectedVersionId: "not-a-uuid" }]) {
|
||||
await expect(callUpdateSkillTool({ arguments: { ...args, ...invalid }, apiUrl: "http://localhost", token: "token", companyId: "company" }, fetcher)).rejects.toThrow();
|
||||
}
|
||||
expect(fetcher).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls the company file endpoint and returns a compact receipt", async () => {
|
||||
const receipt = { skillId: args.skillId, versionId: "next-version", path: "SKILL.md", studioPath: `/skills/studio/${args.skillId}` };
|
||||
const fetcher = vi.fn().mockResolvedValue(new Response(JSON.stringify({ ...receipt, content: args.markdown }), { status: 200 }));
|
||||
expect(await callUpdateSkillTool({ arguments: args, apiUrl: "http://localhost/api", token: "token", companyId: "company" }, fetcher)).toEqual(receipt);
|
||||
expect(fetcher).toHaveBeenCalledWith(`http://localhost/api/companies/company/skills/${args.skillId}/files`, expect.objectContaining({ method: "PATCH" }));
|
||||
expect(JSON.parse(fetcher.mock.calls[0]![1].body)).toEqual({ path: "SKILL.md", content: args.markdown,
|
||||
expectedVersionId: args.expectedVersionId, idempotencyKey: args.idempotencyKey });
|
||||
fetcher.mockResolvedValueOnce(new Response(JSON.stringify({ error: "Skill version changed" }), { status: 409 }));
|
||||
await expect(callUpdateSkillTool({ arguments: args, apiUrl: "http://localhost", token: "token", companyId: "company" }, fetcher)).rejects.toThrow("Skill version changed");
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,35 @@ export const createSkillToolInput = z.object({
|
||||
}
|
||||
});
|
||||
|
||||
/** Replace the complete primary file of an existing skill, never a partial patch. */
|
||||
export const updateSkillToolInput = z.object({
|
||||
skillId: z.string().guid(),
|
||||
markdown: z.string().min(1).max(200000),
|
||||
expectedVersionId: z.string().guid(),
|
||||
idempotencyKey: z.string().min(1).max(240),
|
||||
}).strict().superRefine((input, context) => {
|
||||
const document = parseFrontmatterMarkdown(input.markdown);
|
||||
const metadata = skillFrontmatterSchema.safeParse(document.frontmatter);
|
||||
if (!document.hasFrontmatter || !metadata.success || !document.body.trim()) {
|
||||
context.addIssue({ code: "custom", path: ["markdown"], message: "Provide a complete SKILL.md with valid name and description frontmatter and a nonempty body." });
|
||||
}
|
||||
});
|
||||
|
||||
export async function callUpdateSkillTool(input: {
|
||||
arguments: Record<string, unknown>; apiUrl: string; token: string; companyId: string;
|
||||
}, fetcher: typeof fetch = fetch) {
|
||||
const { skillId, markdown, expectedVersionId, idempotencyKey } = updateSkillToolInput.parse(input.arguments);
|
||||
const response = await fetcher(`${input.apiUrl.replace(/\/+$/, "").replace(/\/api$/, "")}/api/companies/${encodeURIComponent(input.companyId)}/skills/${encodeURIComponent(skillId)}/files`, {
|
||||
method: "PATCH",
|
||||
headers: { Authorization: `Bearer ${input.token}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path: "SKILL.md", content: markdown, expectedVersionId, idempotencyKey }),
|
||||
signal: AbortSignal.timeout(60_000),
|
||||
});
|
||||
const result = await response.json();
|
||||
if (!response.ok) throw new Error(typeof result.error === "string" ? result.error : `Skill update failed (${response.status})`);
|
||||
return { skillId: result.skillId, versionId: result.versionId, path: result.path, studioPath: result.studioPath };
|
||||
}
|
||||
|
||||
/** Use the same API and company skill policy as Skill Studio. */
|
||||
export async function callCreateSkillTool(input: {
|
||||
arguments: Record<string, unknown>; apiUrl: string; token: string; companyId: string;
|
||||
|
||||
Reference in new issue
Block a user