mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 09:07:46 +02:00
## Thinking Path > - Paperclip manages shared skills and agent assignments. > - Runtime plugins need installed skills before a task starts. > - Skill content can change without changing the agent row. > - Source access and version pins also affect delivered files. > - This pull request feeds those changes into the durable runtime stream. > - The runtime reader resolves the same immutable skill sources used for execution. ## Linked Issues or Issue Description Builds on merged PR #16499. This pull request now targets `master` and contains only the five skill synchronization commits. **Problem** Plugins receive no durable notification when an assigned skill changes. A saved assignment can also become inaccessible after private-source access changes. **Proposed solution** Invalidate referencing agents at installed skill mutations. Invalidate inherited access when source bindings, project privacy, or policy change. Return authorized skill sources and version IDs through the owning runtime reader. **Alternatives considered** Activity-only subscriptions miss service writers. Per-task synchronization adds preparation work. Copying current files would ignore version pins. **Roadmap alignment** This extends existing skills, source synchronization, and plugin runtime support. ## What Changed - Defer multi-skill source runtime hooks until commit, after publication holds all skill rows. Keep file-save journaling immediate to preserve filesystem rollback. - Add transactional invalidations for skill content, companion files, versions, rename, deletion, policy, source access, and pin-setting changes. - Recognize canonical keys and supported legacy aliases. - Reuse the execution skill resolver, current agent access, and instance version-pin rules. - Resolve and materialize sources outside the agent row lock. Recheck ownership and runtime revision afterward. - Keep task-only mentions and running-task snapshots in the execution path. - Document conservative invalidations for pinned assignments and inherited access. ## Verification - Rebased onto origin/master `9f4a956b90`, which contains merged PR #16499. All five commits are unchanged according to `git range-diff`; no conflict resolutions or source changes were needed. - On rebased head `58b065941`, all 237 focused tests passed: runtime skill hooks/source publication (41), plus content service, assignment routes, policy routes, and project source access (196). - Server typecheck passed after rebuilding Runner with the installed Rust toolchain. Migration numbering/safety and map checks passed; snapshot pruning made no changes. Migration CLI checks required local IPC permission. - Real PostgreSQL coverage includes concurrent source publication/version saves, policy-only tightening/restoration, referencing-agent fanout, company isolation, no-op counters, supporting files, rename/deletion, rollback, private-source access, pinned/latest bytes, and unassignment. - Earlier stack validation passed workspace typecheck/build. Fresh hosted CI and review must verify the rebased head before handoff. - The broad local suite was previously limited by an unchanged diagnostic test that rejects "private" in an error stack under this /private/tmp worktree; that unchanged suite passed all 108 tests in the original checkout. Hosted full-suite checks provide exact-head verification. - Diff whitespace and full PR secret/PII scan passed. - All hosted CI/security checks are green on `58b065941`; fresh standard Greptile and Apex both returned 5/5 with no findings or unresolved threads. The PR targets master and is mergeable without conflicts. ## Risks - Apply migration 0348 after the runtime-stream migration in #16499. - Content invalidations include pinned assignments. The resolved version stays pinned; consumers can avoid writes when its version/hash is unchanged. - Project access and source changes invalidate only agents assigned skills from the affected sources. Company policy still invalidates the company. Source polling bookkeeping and popularity counters do not create work. - The reader rebuilds installed caches without upstream requests. Concurrent changes cause a bounded retry and then a conflict. - Private transport integration and live remote computer qualification remain separate. ## Model Used OpenAI Codex, GPT-6, with reasoning, repository inspection, code execution, and testing. The exact deployment model ID and context window are not exposed in this session. ## 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 not referenced internal/instance-local Paperclip issues or links (only public GitHub references) - [x] My branch name describes the change and contains no internal Paperclip ticket id - [x] I have run tests locally and they pass (focused suites; broad local limitation documented above) - [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 - [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>
160 lines
13 KiB
TypeScript
160 lines
13 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { and, eq } from "drizzle-orm";
|
|
import { agents, companies, companyMemberships, companySkills, companySkillVersions, companySkillSources, projectSkillSources, projects, projectAccessMembers, createDb, instanceSettings, pluginConfig, plugins, resourceLifecycleEvents, type Db } from "@paperclipai/db";
|
|
import { LOW_TRUST_REVIEW_PRESET } from "@paperclipai/shared";
|
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
|
import { pluginAgentRuntimeState } from "../services/plugin-agent-runtime-state.js";
|
|
import { instanceSettingsService } from "../services/instance-settings.js";
|
|
|
|
const support = await getEmbeddedPostgresTestSupport();
|
|
const describeDatabase = support.supported ? describe : describe.skip;
|
|
|
|
describeDatabase("skill runtime invalidation", () => {
|
|
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
|
let db: Db;
|
|
let home: string;
|
|
let companyId: string;
|
|
let pluginId: string;
|
|
let skillId: string;
|
|
let key: string;
|
|
beforeAll(async () => {
|
|
database = await startEmbeddedPostgresTestDatabase("paperclip-runtime-skills-"); db = createDb(database.connectionString);
|
|
home = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-skills-home-"));
|
|
}, 90_000);
|
|
afterAll(async () => { await database?.cleanup(); await fs.rm(home, { recursive: true, force: true }); });
|
|
afterEach(() => vi.unstubAllEnvs());
|
|
beforeEach(async () => {
|
|
vi.stubEnv("PAPERCLIP_HOME", home);
|
|
companyId = randomUUID(); pluginId = randomUUID(); skillId = randomUUID(); key = `company/${companyId}/fixture`;
|
|
await db.insert(companies).values({ id: companyId, name: "Skill runtime fixture", issuePrefix: `S${companyId.slice(0, 6)}` });
|
|
await db.insert(plugins).values({ id: pluginId, pluginKey: pluginId, packageName: "runtime-fixture", version: "1.0.0", status: "ready", manifestJson: { resourceLifecycle: ["agent_runtime"], capabilities: ["events.subscribe", "agents.lifecycle.manage"] } as never });
|
|
await db.insert(pluginConfig).values({ companyId, pluginId, configJson: {} });
|
|
await db.insert(companySkills).values({ id: skillId, companyId, key, slug: "fixture", name: "Fixture", markdown: "old", sourceType: "github", sourceLocator: "https://github.com/fixture/skills" });
|
|
});
|
|
async function agent(selection: unknown[] = [key], company = companyId) {
|
|
const [created] = await db.insert(agents).values({ companyId: company, name: randomUUID(), adapterType: "paperclip_runner", lifecycleRequiredPluginIds: [pluginId], adapterConfig: { paperclipSkillSync: { desiredSkills: selection } } }).returning();
|
|
return created;
|
|
}
|
|
const events = () => db.select().from(resourceLifecycleEvents).where(and(eq(resourceLifecycleEvents.companyId, companyId), eq(resourceLifecycleEvents.resourceType, "agent_runtime"))).orderBy(resourceLifecycleEvents.id);
|
|
async function publish(content: string, revision: number) {
|
|
return db.transaction(async tx => {
|
|
const [version] = await tx.insert(companySkillVersions).values({ companyId, companySkillId: skillId, revisionNumber: revision, fileInventory: [{ path: "SKILL.md", kind: "skill", content }] }).returning();
|
|
await tx.update(companySkills).set({ markdown: content, currentVersionId: version.id }).where(eq(companySkills.id, skillId));
|
|
return version;
|
|
});
|
|
}
|
|
it("invalidates string, structured, and legacy alias assignments while leaving other agents untouched", async () => {
|
|
const first = await agent(); const second = await agent([{ key, versionId: randomUUID() }]); const alias = await agent(["fixture"]);
|
|
await agent([]); await agent(["unrelated"]);
|
|
const otherCompany = randomUUID();
|
|
await db.insert(companies).values({ id: otherCompany, name: "Other company", issuePrefix: `X${otherCompany.slice(0, 6)}` });
|
|
await agent([key], otherCompany);
|
|
const before = (await events()).length;
|
|
await db.update(companySkills).set({ markdown: "changed" }).where(eq(companySkills.id, skillId));
|
|
expect((await events()).slice(before).map(row => row.resourceId).sort()).toEqual([first.id, second.id, alias.id].sort());
|
|
const after = (await events()).length;
|
|
await db.update(companySkills).set({ starCount: 9, updatedAt: new Date() }).where(eq(companySkills.id, skillId));
|
|
expect(await events()).toHaveLength(after);
|
|
});
|
|
it.each(["paperclip", "complain", "suggestion-box"])("invalidates implicit legacy %s skills with an empty selection", async slug => {
|
|
const legacy = await agent([]);
|
|
const native = await agent([]);
|
|
await db.update(agents).set({ adapterType: "process" }).where(eq(agents.id, legacy.id));
|
|
const [skill] = await db.insert(companySkills).values({ companyId, key: `paperclipai/paperclip/${slug}`, slug, name: slug, markdown: "implicit" }).returning();
|
|
const before = (await events()).length;
|
|
await db.update(companySkills).set({ markdown: "changed implicit" }).where(eq(companySkills.id, skill.id));
|
|
expect((await events()).slice(before)).toEqual([expect.objectContaining({ resourceId: legacy.id })]);
|
|
await db.delete(companySkills).where(eq(companySkills.id, skill.id));
|
|
expect((await events()).slice(before).map(row => row.resourceId)).toEqual([legacy.id, legacy.id]);
|
|
expect((await events()).slice(before).some(row => row.resourceId === native.id)).toBe(false);
|
|
});
|
|
it("captures companion-file changes, rename, and deletion using both old and new references", async () => {
|
|
const old = await agent(); const next = await agent([`${key}-next`]);
|
|
const before = (await events()).length;
|
|
await db.update(companySkills).set({ fileInventory: [{ path: "references/guide.md", kind: "reference", size: 5 }] }).where(eq(companySkills.id, skillId));
|
|
expect((await events()).slice(before)).toEqual([expect.objectContaining({ resourceId: old.id })]);
|
|
const renamed = (await events()).length;
|
|
await db.update(companySkills).set({ key: `${key}-next`, slug: "next" }).where(eq(companySkills.id, skillId));
|
|
expect((await events()).slice(renamed).map(row => row.resourceId).sort()).toEqual([old.id, next.id].sort());
|
|
const deleted = (await events()).length;
|
|
await db.delete(companySkills).where(eq(companySkills.id, skillId));
|
|
expect((await events()).slice(deleted)).toEqual([expect.objectContaining({ resourceId: next.id })]);
|
|
});
|
|
it("invalidates private-source access changes while ignoring source polling bookkeeping", async () => {
|
|
const assigned = await agent();
|
|
const [project] = await db.insert(projects).values({ companyId, name: "Private source", visibility: "private" }).returning();
|
|
const [source] = await db.insert(companySkillSources).values({ companyId, repositoryUrl: "https://github.com/fixture/private", fullName: "fixture/private", trackingRef: "HEAD", accessScope: "projects" }).returning();
|
|
await db.update(companySkills).set({ accessSourceId: source.id }).where(eq(companySkills.id, skillId));
|
|
const [binding] = await db.insert(projectSkillSources).values({ companyId, projectId: project.id, sourceId: source.id, repositoryUrl: source.repositoryUrl, trackingRef: "HEAD" }).returning();
|
|
await db.insert(agents).values(Array.from({ length: 250 }, () => ({ companyId, name: randomUUID(), adapterType: "paperclip_runner", adapterConfig: { paperclipSkillSync: { desiredSkills: ["unrelated"] } } })));
|
|
const before = (await events()).length;
|
|
await db.update(projectSkillSources).set({ nextSyncAt: new Date(), failureCount: 1 }).where(eq(projectSkillSources.id, binding.id));
|
|
expect(await events()).toHaveLength(before);
|
|
const [access] = await db.insert(projectAccessMembers).values({ companyId, projectId: project.id, subjectType: "agent", subjectId: assigned.id }).returning();
|
|
expect(await events()).toHaveLength(before + 1);
|
|
await db.delete(projectAccessMembers).where(eq(projectAccessMembers.id, access.id));
|
|
expect(await events()).toHaveLength(before + 2);
|
|
await db.update(projects).set({ visibility: "open" }).where(eq(projects.id, project.id));
|
|
expect(await events()).toHaveLength(before + 3);
|
|
expect((await events()).slice(before).map(row => row.resourceId)).toEqual([assigned.id, assigned.id, assigned.id]);
|
|
const [unrelatedProject] = await db.insert(projects).values({ companyId, name: "Unrelated", visibility: "private" }).returning();
|
|
await db.insert(projectAccessMembers).values({ companyId, projectId: unrelatedProject.id, subjectType: "agent", subjectId: assigned.id });
|
|
await db.update(projects).set({ visibility: "open" }).where(eq(projects.id, unrelatedProject.id));
|
|
expect(await events()).toHaveLength(before + 3);
|
|
await db.update(projectSkillSources).set({ sourceId: null }).where(eq(projectSkillSources.id, binding.id));
|
|
expect((await events()).slice(before + 3)).toEqual([expect.objectContaining({ resourceId: assigned.id })]);
|
|
});
|
|
it("keeps publication and invalidation in the same transaction", async () => {
|
|
await agent(); const before = await events();
|
|
await expect(db.transaction(async tx => {
|
|
await tx.update(companySkills).set({ markdown: "rolled back" }).where(eq(companySkills.id, skillId));
|
|
throw new Error("rollback");
|
|
})).rejects.toThrow("rollback");
|
|
expect(await events()).toEqual(before);
|
|
expect((await db.select().from(companySkills).where(eq(companySkills.id, skillId)))[0].markdown).toBe("old");
|
|
});
|
|
it("invalidates policy-only project access changes and removes or restores the selected skill", async () => {
|
|
const assigned = await agent();
|
|
await agent([]);
|
|
await db.insert(companyMemberships).values({ companyId, principalType: "agent", principalId: assigned.id, status: "active" });
|
|
const [project] = await db.insert(projects).values({ companyId, name: "Policy-bound source", visibility: "open" }).returning();
|
|
const [source] = await db.insert(companySkillSources).values({ companyId, repositoryUrl: "https://github.com/fixture/policy", fullName: "fixture/policy", trackingRef: "HEAD", accessScope: "projects" }).returning();
|
|
await db.insert(projectSkillSources).values({ companyId, projectId: project.id, sourceId: source.id, repositoryUrl: source.repositoryUrl, trackingRef: "HEAD" });
|
|
await db.update(companySkills).set({ accessSourceId: source.id }).where(eq(companySkills.id, skillId));
|
|
await publish("policy-scoped content", 1);
|
|
const state = () => pluginAgentRuntimeState(db, pluginId, companyId, assigned.id);
|
|
expect((await state())!.skills.map(skill => skill.key)).toEqual([key]);
|
|
const before = (await events()).length;
|
|
const restricted = { authorizationPolicy: { trustBoundary: { mode: LOW_TRUST_REVIEW_PRESET, projectIds: [] } } };
|
|
await db.update(projects).set({ executionWorkspacePolicy: restricted }).where(eq(projects.id, project.id));
|
|
expect((await events()).slice(before).map(event => event.resourceId)).toEqual([assigned.id]);
|
|
expect((await state())!.skills).toEqual([]);
|
|
await db.update(projects).set({ executionWorkspacePolicy: null }).where(eq(projects.id, project.id));
|
|
expect((await events()).slice(before).map(event => event.resourceId)).toEqual([assigned.id, assigned.id]);
|
|
expect((await state())!.skills.map(skill => skill.key)).toEqual([key]);
|
|
const restored = (await events()).length;
|
|
await db.update(projects).set({ executionWorkspacePolicy: null }).where(eq(projects.id, project.id));
|
|
expect(await events()).toHaveLength(restored);
|
|
});
|
|
it("resolves immutable pinned and latest skill sources and removes unassigned skills", async () => {
|
|
await instanceSettingsService(db).getExperimental();
|
|
await db.update(instanceSettings).set({ experimental: { enableBetaSkills: true } });
|
|
const first = await publish("first version", 1);
|
|
const pinned = await agent([{ key, versionId: first.id }]);
|
|
const latest = await agent();
|
|
const second = await publish("second version", 2);
|
|
const pinnedState = await pluginAgentRuntimeState(db, pluginId, companyId, pinned.id);
|
|
const latestState = await pluginAgentRuntimeState(db, pluginId, companyId, latest.id);
|
|
expect(pinnedState!.skills).toEqual([expect.objectContaining({ key, versionId: first.id })]);
|
|
expect(latestState!.skills).toEqual([expect.objectContaining({ key, versionId: second.id })]);
|
|
expect(await fs.readFile(path.join(pinnedState!.skills[0].source, "SKILL.md"), "utf8")).toBe("first version");
|
|
expect(await fs.readFile(path.join(latestState!.skills[0].source, "SKILL.md"), "utf8")).toBe("second version");
|
|
await db.update(agents).set({ adapterConfig: { paperclipSkillSync: { desiredSkills: [] } } }).where(eq(agents.id, latest.id));
|
|
expect((await pluginAgentRuntimeState(db, pluginId, companyId, latest.id))!.skills).toEqual([]);
|
|
});
|
|
});
|