mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 11:01:16 +01:00
Merge current master and regenerate the delegated token migration
Preserve the upstream environment-skill runtime migration and regenerate the gateway responsible-user column as migration 0379. Keep the additive column idempotent. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
commit
db3190fe13
247 files changed
+14906
-803
No files matched your search
@@ -9,7 +9,7 @@ import { createDatabaseResponseCapture, installDatabaseResponseCapture } from "@
|
||||
import { githubResponseAuthorityFields } from "../services/chat-channels/github/response-authority-read.js";
|
||||
import * as activityLogModule from "../services/activity-log.js";
|
||||
import * as connectionCredentialBindings from "../services/connection-credential-bindings.js";
|
||||
import { chatSlackManagerGrants, chatSlackRegistrations, toolOauthStates } from "@paperclipai/db";
|
||||
import { chatSlackManagerGrants, chatSlackRegistrations, toolOauthStates, environments } from "@paperclipai/db";
|
||||
import type { PaperclipCloudConnector, SealedConnectorCredentials } from "../services/paperclip-cloud-connector.js";
|
||||
import { buildSlackAppManifest, SLACK_MANAGER_SCOPES } from "@paperclipai/shared";
|
||||
import { registerChatActionWork, dueChatAction } from "../services/chat-action-work.js";
|
||||
@@ -61438,7 +61438,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
|
||||
);
|
||||
|
||||
it.each((["github", "telegram"] as const).flatMap(provider =>
|
||||
(["pending", "historical_sibling", "foreign_source_hold", "automatic_scheduled", "automatic_running", "automatic_awaiting_evidence", "exhausted", "wrong_generation", "paused_endpoint", "paused_after_wait", "paused_agent_after_wait", "edited_after_wait", "superseded_after_wait"] as const).map(gate => ({ provider, gate }))))(
|
||||
(["unregistered", "pending", "historical_sibling", "foreign_source_hold", "automatic_scheduled", "automatic_running", "automatic_awaiting_evidence", "exhausted", "wrong_generation", "paused_endpoint", "paused_after_wait", "paused_agent_after_wait", "edited_after_wait", "superseded_after_wait"] as const).map(gate => ({ provider, gate }))))(
|
||||
"holds an ordinary $provider Retry behind its exact remote recovery: $gate", async ({ provider, gate }) => {
|
||||
const context = await failedChatRetryFixture(provider, undefined, true);
|
||||
try {
|
||||
@@ -61465,7 +61465,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
|
||||
firstFailureAt: Date.now() - 1000, deadlineAt: Date.now() + 60_000,
|
||||
preservationOperationIds: [randomUUID()],
|
||||
};
|
||||
const canResume = gate === "pending" || gate === "historical_sibling" || gate.startsWith("automatic_");
|
||||
const canResume = gate === "unregistered" || gate === "pending" || gate === "historical_sibling" || gate.startsWith("automatic_");
|
||||
await db.insert(issueRecoveryActions).values({ companyId, sourceIssueId: issueId,
|
||||
kind: "active_run_watchdog", status: "resolved", ownerType: "system",
|
||||
cause: "native_continuation_requires_reconciliation", fingerprint: randomUUID(),
|
||||
@@ -61476,6 +61476,19 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
|
||||
systemRecovery: priorRecovery,
|
||||
},
|
||||
});
|
||||
if (gate === "unregistered") {
|
||||
const [lease] = await db.select().from(environmentLeases).where(eq(environmentLeases.id, leaseId));
|
||||
const [environment] = await db.insert(environments).values({ name: context.runId, driver: "sandbox" }).returning();
|
||||
await db.delete(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, issueId));
|
||||
await db.update(heartbeatRuns).set({ resultJson: null, errorCode: "execution_finalization_deadline_exceeded",
|
||||
runnerProfileJson: { nativeWorkspaceSync: { schema: "paperclip.native-workspace-sync/v3", state: "prepared",
|
||||
descriptorSha256: "a".repeat(64), baselineSha256: "b".repeat(64), finalHostSha256: null,
|
||||
workspaceId: issueId, leaseId, providerLeaseId: lease.providerLeaseId,
|
||||
remoteCwd: lease.metadata.remoteCwd, resourceDisposition: "keep_running" } },
|
||||
}).where(eq(heartbeatRuns.id, context.runId));
|
||||
await db.update(environmentLeases).set({ environmentId: environment.id, status: "active", cleanupStatus: null, leasePolicy: "reuse_by_environment" })
|
||||
.where(eq(environmentLeases.id, leaseId));
|
||||
}
|
||||
const receipt = await requestTaskRetry({ db, companyId, issueId, actorId: "owner-user",
|
||||
actorSource: "cloud_tenant", observedRunId: context.runId, idempotencyKey: randomUUID(),
|
||||
prepareChatRetry: context.service.prepareFailedChatRunRetry });
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import { createHash, 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, companySkills, createDb, environments, pluginCompanySettings, plugins, resourceLifecycleEvents, type Db } from "@paperclipai/db";
|
||||
import type { PaperclipPluginManifestV1 } from "@paperclipai/shared";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
import { environmentSkillConfig } from "../services/environment-skills.js";
|
||||
import { pluginAgentRuntimeState } from "../services/plugin-agent-runtime-state.js";
|
||||
import { companySkillService } from "../services/company-skills.js";
|
||||
import { instanceSettingsService } from "../services/instance-settings.js";
|
||||
import { withSkillActor } from "../services/skill-access.js";
|
||||
import { buildNativeRuntimeContext } from "../services/native-runtime/runtime-context.js";
|
||||
|
||||
const support = await getEmbeddedPostgresTestSupport();
|
||||
const describeDatabase = support.supported ? describe : describe.skip;
|
||||
describeDatabase("environment managed skills", () => {
|
||||
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
let db: Db;
|
||||
let home: string;
|
||||
let companyId: string;
|
||||
let pluginId: string;
|
||||
let manifest: PaperclipPluginManifestV1;
|
||||
let environment: { driver: string; config: Record<string, unknown> };
|
||||
beforeAll(async () => {
|
||||
database = await startEmbeddedPostgresTestDatabase("paperclip-environment-skills-"); db = createDb(database.connectionString);
|
||||
home = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-environment-skills-"));
|
||||
}, 90_000);
|
||||
afterAll(async () => { await database?.cleanup(); if (home) {
|
||||
const release = async (directory: string): Promise<void> => {
|
||||
await fs.chmod(directory, 0o700);
|
||||
for (const entry of await fs.readdir(directory, { withFileTypes: true })) if (entry.isDirectory()) await release(path.join(directory, entry.name));
|
||||
};
|
||||
await release(home); await fs.rm(home, { recursive: true, force: true });
|
||||
} });
|
||||
afterEach(() => vi.unstubAllEnvs());
|
||||
beforeEach(async () => {
|
||||
vi.stubEnv("PAPERCLIP_HOME", home);
|
||||
companyId = randomUUID(); pluginId = randomUUID();
|
||||
manifest = { id: `fixture.${pluginId}`, apiVersion: 1, version: "1.0.0", displayName: "Desktop", description: "Fixture", author: "Tests", categories: ["automation"],
|
||||
capabilities: ["environment.drivers.register", "skills.managed", "agents.lifecycle.manage", "events.subscribe"],
|
||||
resourceLifecycle: ["agent_runtime"], entrypoints: { worker: "worker.js" },
|
||||
environmentDrivers: [{ driverKey: "desktop", displayName: "Desktop", skillKeys: ["computer-use"], configSchema: {} }],
|
||||
skills: [{ skillKey: "computer-use", displayName: "Computer use", slug: "computer-use",
|
||||
markdown: "---\nname: computer-use\ndescription: Use the installed desktop tools.\n---\n\nRead references/linux.md.",
|
||||
files: [{ path: "references/linux.md", content: "Use the installed cua-driver." }] }] };
|
||||
await db.insert(companies).values({ id: companyId, name: "Environment skills", issuePrefix: `E${companyId.slice(0, 6)}` });
|
||||
await db.insert(plugins).values({ id: pluginId, pluginKey: manifest.id, packageName: "fixture", version: "1.0.0", status: "ready", manifestJson: manifest });
|
||||
environment = { driver: "plugin", config: { pluginKey: manifest.id, driverKey: "desktop", driverConfig: {} } };
|
||||
});
|
||||
it("installs the company skill and delivers its versioned files through runtime sync and the native snapshot", async () => {
|
||||
const config = { paperclipSkillSync: { desiredSkills: [] } };
|
||||
const [selection, concurrent] = await Promise.all([environmentSkillConfig(db, companyId, environment, config), environmentSkillConfig(db, companyId, environment, config)]);
|
||||
expect(concurrent).toEqual(selection);
|
||||
const selected = selection.config;
|
||||
const [skill] = await db.select().from(companySkills).where(and(eq(companySkills.companyId, companyId), eq(companySkills.key, `plugin/fixture-${pluginId}/computer-use`)));
|
||||
expect(selected).toMatchObject({ paperclipSkillSync: { desiredSkills: [skill.key] } });
|
||||
expect(config.paperclipSkillSync.desiredSkills).toEqual([]);
|
||||
const [env] = await db.insert(environments).values({ name: "Desktop", ...environment }).returning();
|
||||
const [agent] = await db.insert(agents).values({ companyId, name: "Worker", adapterType: "paperclip_runner", defaultEnvironmentId: env.id,
|
||||
lifecycleRequiredPluginIds: [pluginId], adapterConfig: config }).returning();
|
||||
const state = await pluginAgentRuntimeState(db, pluginId, companyId, agent.id);
|
||||
expect(state!.skills).toEqual([expect.objectContaining({ key: skill.key, runtimeName: expect.stringMatching(/^plugin-.*-computer-use--/), versionId: expect.any(String) })]);
|
||||
await withSkillActor({ type: "agent", agentId: agent.id, companyId, source: "agent_key" }, async () => {
|
||||
await companySkillService(db).withRuntimeSkillEntries(companyId, { selectionConfig: selected, snapshotLatest: true }, async entries => {
|
||||
const context = await buildNativeRuntimeContext({ db, agent, runId: randomUUID(), runtimeConfig: selected, runtimeSkillEntries: entries });
|
||||
expect(context.skills).toEqual([expect.objectContaining({ key: skill.key, runtimeName: state!.skills[0].runtimeName, versionId: state!.skills[0].versionId })]);
|
||||
expect(await fs.readFile(path.join(context.skills[0].bundle.rootPath, "references/linux.md"), "utf8")).toContain("cua-driver");
|
||||
});
|
||||
});
|
||||
await db.update(agents).set({ defaultEnvironmentId: null }).where(eq(agents.id, agent.id));
|
||||
await instanceSettingsService(db).update({ defaultEnvironmentId: env.id });
|
||||
expect((await pluginAgentRuntimeState(db, pluginId, companyId, agent.id))!.skills).toEqual(state!.skills);
|
||||
await instanceSettingsService(db).update({ defaultEnvironmentId: null });
|
||||
expect((await pluginAgentRuntimeState(db, pluginId, companyId, agent.id))!.skills).toEqual([]);
|
||||
expect((await pluginAgentRuntimeState(db, pluginId, companyId, agent.id, env.id))!.skills).toEqual(state!.skills);
|
||||
await expect(pluginAgentRuntimeState(db, pluginId, companyId, agent.id, randomUUID())).rejects.toMatchObject({ status: 422 });
|
||||
const invalidSkill = { skillKey: "new-invalid", displayName: "New invalid skill", markdown: "---\nname: new-invalid\nmetadata:\n sources:\n - kind: github-dir\n repo: fixture/skills\n---\nUnpinned." };
|
||||
const changedManifest = { ...manifest, skills: [...manifest.skills!, invalidSkill], environmentDrivers: manifest.environmentDrivers!.map(driver => ({ ...driver, skillKeys: [...driver.skillKeys!, "new-invalid"] })) };
|
||||
await db.update(plugins).set({ manifestJson: changedManifest }).where(eq(plugins.id, pluginId));
|
||||
expect((await pluginAgentRuntimeState(db, pluginId, companyId, agent.id, env.id))!.skills).toEqual(state!.skills);
|
||||
await expect(environmentSkillConfig(db, companyId, environment, config)).rejects.toThrow(/pinned Git commit/);
|
||||
await db.update(plugins).set({ manifestJson: manifest }).where(eq(plugins.id, pluginId));
|
||||
const pinned = { paperclipSkillSync: { desiredSkills: [{ key: skill.key, versionId: state!.skills[0].versionId }] } };
|
||||
expect((await environmentSkillConfig(db, companyId, environment, pinned)).config).toEqual(pinned);
|
||||
await db.update(companySkills).set({ markdown: "Agent customization" }).where(eq(companySkills.id, skill.id));
|
||||
await environmentSkillConfig(db, companyId, environment, config);
|
||||
expect((await db.select().from(companySkills).where(eq(companySkills.id, skill.id)))[0].markdown).toBe("Agent customization");
|
||||
});
|
||||
it("installs and refreshes environment skills through lifecycle reconciliation before any run", async () => {
|
||||
const [env] = await db.insert(environments).values({ name: randomUUID(), ...environment }).returning();
|
||||
const [agent] = await db.insert(agents).values({ companyId, name: "Background worker", adapterType: "paperclip_runner", defaultEnvironmentId: env.id,
|
||||
lifecycleRequiredPluginIds: [pluginId], adapterConfig: { paperclipSkillSync: { desiredSkills: [] } } }).returning();
|
||||
expect((await pluginAgentRuntimeState(db, pluginId, companyId, agent.id))!.skills).toEqual([]);
|
||||
const states = await Promise.all([1, 2].map(() => pluginAgentRuntimeState(db, pluginId, companyId, agent.id, undefined, true)));
|
||||
expect(states[0]!.skills).toEqual(states[1]!.skills);
|
||||
const [skill] = await db.select().from(companySkills).where(and(eq(companySkills.companyId, companyId), eq(companySkills.key, `plugin/fixture-${pluginId}/computer-use`)));
|
||||
expect(skill.slug).toMatch(/^plugin-.*-computer-use$/);
|
||||
const runtime = states[0]!.skills[0];
|
||||
expect(await fs.readFile(path.join(runtime.source, "SKILL.md"), "utf8")).toContain(`name: "${skill.slug}"`);
|
||||
const events = () => db.select().from(resourceLifecycleEvents).where(and(eq(resourceLifecycleEvents.companyId, companyId), eq(resourceLifecycleEvents.resourceType, "agent_runtime")));
|
||||
const before = (await events()).length;
|
||||
const edited = skill.markdown + "\nCompany edit.";
|
||||
await fs.chmod(path.join(skill.sourceLocator!, "SKILL.md"), 0o600);
|
||||
await fs.writeFile(path.join(skill.sourceLocator!, "SKILL.md"), edited);
|
||||
await db.update(companySkills).set({ markdown: edited }).where(eq(companySkills.id, skill.id));
|
||||
expect((await events()).slice(before).map(event => event.resourceId)).toEqual([agent.id]);
|
||||
const refreshed = (await pluginAgentRuntimeState(db, pluginId, companyId, agent.id, undefined, true))!.skills[0];
|
||||
expect(refreshed.versionId).not.toBe(runtime.versionId);
|
||||
expect(await fs.readFile(path.join(refreshed.source, "SKILL.md"), "utf8")).toContain("Company edit.");
|
||||
const settled = (await events()).length;
|
||||
await pluginAgentRuntimeState(db, pluginId, companyId, agent.id, undefined, true);
|
||||
expect(await events()).toHaveLength(settled);
|
||||
const changedManifest = { ...manifest, skills: [...manifest.skills!, { skillKey: "extra", displayName: "Extra" }],
|
||||
environmentDrivers: manifest.environmentDrivers!.map(driver => ({ ...driver, skillKeys: [...driver.skillKeys!, "extra"] })) };
|
||||
await db.update(plugins).set({ manifestJson: changedManifest }).where(eq(plugins.id, pluginId));
|
||||
expect((await events()).slice(settled).map(event => event.resourceId)).toEqual([agent.id]);
|
||||
expect((await pluginAgentRuntimeState(db, pluginId, companyId, agent.id, undefined, true))!.skills).toHaveLength(2);
|
||||
const saved = await db.select().from(agents).where(eq(agents.id, agent.id));
|
||||
expect(saved[0].adapterConfig).toEqual(agent.adapterConfig);
|
||||
await db.delete(companySkills).where(eq(companySkills.id, skill.id));
|
||||
expect((await pluginAgentRuntimeState(db, pluginId, companyId, agent.id, undefined, true))!.skills).toHaveLength(2);
|
||||
});
|
||||
it("queues background delivery for inherited defaults and changed environment requirements", async () => {
|
||||
const [env] = await db.insert(environments).values({ name: randomUUID(), ...environment }).returning();
|
||||
const [agent] = await db.insert(agents).values({ companyId, name: "Inherited worker", adapterType: "paperclip_runner", lifecycleRequiredPluginIds: [pluginId] }).returning();
|
||||
const events = () => db.select().from(resourceLifecycleEvents).where(and(eq(resourceLifecycleEvents.companyId, companyId), eq(resourceLifecycleEvents.resourceType, "agent_runtime")));
|
||||
let before = (await events()).length;
|
||||
await instanceSettingsService(db).update({ defaultEnvironmentId: env.id });
|
||||
expect((await events()).slice(before).map(event => event.resourceId)).toEqual([agent.id]);
|
||||
await pluginAgentRuntimeState(db, pluginId, companyId, agent.id, undefined, true);
|
||||
before = (await events()).length;
|
||||
const [settings] = await db.insert(pluginCompanySettings).values({ companyId, pluginId, enabled: true }).returning();
|
||||
for (let change = 0; change < 3; change++) {
|
||||
await db.update(pluginCompanySettings).set({ settingsJson: { localFolders: { path: `fixture-${change}` } } }).where(eq(pluginCompanySettings.id, settings.id));
|
||||
}
|
||||
expect(await events()).toHaveLength(before);
|
||||
await db.update(pluginCompanySettings).set({ enabled: false }).where(eq(pluginCompanySettings.id, settings.id));
|
||||
expect((await events()).slice(before).map(event => event.resourceId)).toEqual([agent.id]);
|
||||
await db.update(pluginCompanySettings).set({ enabled: true }).where(eq(pluginCompanySettings.id, settings.id));
|
||||
expect((await events()).slice(before).map(event => event.resourceId)).toEqual([agent.id, agent.id]);
|
||||
before = (await events()).length;
|
||||
await db.delete(pluginCompanySettings).where(eq(pluginCompanySettings.id, settings.id));
|
||||
expect(await events()).toHaveLength(before);
|
||||
await expect(db.transaction(async tx => {
|
||||
await tx.update(environments).set({ config: { ...environment.config, driverKey: "unrelated" } }).where(eq(environments.id, env.id));
|
||||
throw new Error("rollback");
|
||||
})).rejects.toThrow("rollback");
|
||||
expect(await events()).toHaveLength(before);
|
||||
await db.update(environments).set({ config: { ...environment.config, driverKey: "unrelated" } }).where(eq(environments.id, env.id));
|
||||
expect((await events()).slice(before).map(event => event.resourceId)).toEqual([agent.id]);
|
||||
expect((await pluginAgentRuntimeState(db, pluginId, companyId, agent.id, undefined, true))!.skills).toEqual([]);
|
||||
before = (await events()).length;
|
||||
await instanceSettingsService(db).update({ defaultEnvironmentId: null });
|
||||
expect((await events()).slice(before).map(event => event.resourceId)).toEqual([agent.id]);
|
||||
});
|
||||
it("delivers the required plugin skill and reports a same-name company assignment as shadowed", async () => {
|
||||
const name = `plugin-${pluginId}-computer-use`;
|
||||
const original = await companySkillService(db).createLocalSkill(companyId, { name, markdown: `---\nname: ${name}\n---\nCompany instructions.\n` });
|
||||
const config = { paperclipSkillSync: { desiredSkills: [original.key] } };
|
||||
const [env] = await db.insert(environments).values({ name: randomUUID(), ...environment }).returning();
|
||||
const [agent] = await db.insert(agents).values({ companyId, name: "Conflicting worker", adapterType: "paperclip_runner", defaultEnvironmentId: env.id,
|
||||
lifecycleRequiredPluginIds: [pluginId], adapterConfig: config }).returning();
|
||||
const state = await pluginAgentRuntimeState(db, pluginId, companyId, agent.id, undefined, true);
|
||||
const key = `plugin/fixture-${pluginId}/computer-use`;
|
||||
expect(state!.skills.map(skill => skill.key)).toEqual([key]);
|
||||
expect(state!.shadowedSkills).toEqual([{ key: original.key, shadowedBy: key }]);
|
||||
for (const reference of [name, original.key.toUpperCase(), `${name}--${createHash("sha256").update(original.key).digest("hex").slice(0, 10)}`]) {
|
||||
const aliased = await environmentSkillConfig(db, companyId, environment, { paperclipSkillSync: { desiredSkills: [reference] } });
|
||||
expect(aliased.config).toMatchObject({ paperclipSkillSync: { desiredSkills: [key] } });
|
||||
expect(aliased.shadowedSkills).toEqual(state!.shadowedSkills);
|
||||
}
|
||||
expect((await db.select().from(companySkills).where(eq(companySkills.id, original.id)))[0]).toMatchObject({ markdown: original.markdown, slug: original.slug });
|
||||
expect((await db.select().from(agents).where(eq(agents.id, agent.id)))[0].adapterConfig).toEqual(config);
|
||||
await db.update(agents).set({ defaultEnvironmentId: null }).where(eq(agents.id, agent.id));
|
||||
const restored = await pluginAgentRuntimeState(db, pluginId, companyId, agent.id, undefined, true);
|
||||
expect(restored!.skills.map(skill => skill.key)).toEqual([original.key]);
|
||||
expect(restored!.shadowedSkills).toBeUndefined();
|
||||
});
|
||||
it("keeps installations company-scoped and rejects disabled providers", async () => {
|
||||
const peerId = randomUUID();
|
||||
await db.insert(companies).values({ id: peerId, name: "Peer", issuePrefix: `P${peerId.slice(0, 6)}` });
|
||||
await environmentSkillConfig(db, companyId, environment, {});
|
||||
await environmentSkillConfig(db, peerId, environment, {});
|
||||
const peerSkills = await db.select().from(companySkills).where(and(eq(companySkills.companyId, peerId), eq(companySkills.key, `plugin/fixture-${pluginId}/computer-use`)));
|
||||
expect(peerSkills).toHaveLength(1);
|
||||
const skills = await db.select().from(companySkills).where(and(eq(companySkills.companyId, companyId), eq(companySkills.key, `plugin/fixture-${pluginId}/computer-use`)));
|
||||
expect(peerSkills[0].id).not.toBe(skills[0].id);
|
||||
await db.insert(pluginCompanySettings).values({ companyId, pluginId, enabled: false });
|
||||
await expect(environmentSkillConfig(db, companyId, environment, {})).rejects.toMatchObject({ status: 422 });
|
||||
expect(await environmentSkillConfig(db, companyId, { driver: "local", config: null }, {})).toEqual({ config: {}, skillKeys: [] });
|
||||
expect(await environmentSkillConfig(db, companyId, { ...environment, config: { ...environment.config, driverKey: "unrelated" } }, {})).toEqual({ config: {}, skillKeys: [] });
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,7 @@ import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { agents, authUsers, companies, companyMemberships, companySkills, createDb, heartbeatRunEvents, heartbeatRuns, issues, projects } from "@paperclipai/db";
|
||||
import { agents, authUsers, companies, companyMemberships, companySkills, createDb, heartbeatRunEvents, heartbeatRuns, issues, projects, plugins, environments } from "@paperclipai/db";
|
||||
import { companySkillService } from "../services/company-skills.js";
|
||||
import { withSkillVersionCacheLock } from "../services/skill-version-cache.js";
|
||||
import { registerServerAdapter, unregisterServerAdapter, type AdapterExecutionContext } from "../adapters/index.js";
|
||||
@@ -220,24 +220,71 @@ describePostgres("heartbeat execution configuration through adapter dispatch", (
|
||||
}
|
||||
});
|
||||
|
||||
async function configuration(f: Awaited<ReturnType<typeof seed>>, config: Record<string, unknown>, snapshot: Record<string, unknown> = {}, type = "paperclip_runner", explicitTaskDirectory = false, requestedExecutionWorkspaceMode: HeartbeatExecutionConfigurationInput["policy"]["requestedExecutionWorkspaceMode"] = "shared_workspace") {
|
||||
async function configuration(f: Awaited<ReturnType<typeof seed>>, config: Record<string, unknown>, snapshot: Record<string, unknown> = {}, type = "paperclip_runner", explicitTaskDirectory = false, requestedExecutionWorkspaceMode: HeartbeatExecutionConfigurationInput["policy"]["requestedExecutionWorkspaceMode"] = "shared_workspace", selectedEnvironmentForConfig: HeartbeatExecutionConfigurationInput["environment"]["selectedEnvironmentForConfig"] = null) {
|
||||
const [agent] = await db.update(agents).set({ adapterType: type, adapterConfig: config }).where(eq(agents.id, f.agentId)).returning();
|
||||
const [run] = await db.insert(heartbeatRuns).values({ companyId: f.companyId, agentId: f.agentId, issueId: f.issueId, scopeKind: "issue", status: "running", contextSnapshot: snapshot }).returning();
|
||||
const resolvedInstanceSettings = await instanceSettingsService(db).get();
|
||||
return { run, prepare: () => prepareHeartbeatExecutionConfiguration(db, {
|
||||
const appendRunEvent = vi.fn();
|
||||
return { run, appendRunEvent, prepare: () => prepareHeartbeatExecutionConfiguration(db, {
|
||||
run, agent,
|
||||
task: { issueId: f.issueId, context: { ...snapshot, issueId: f.issueId }, responsibleUserId: null,
|
||||
acceptedPlanContinuationWake: false, acceptedPlanWakeRoutingDecision: null,
|
||||
taskProjectContext: null, routineEnvContext: { routineId: null, env: null, responsibleUserId: null } },
|
||||
config: { sourceWorkspaceConfig: config, aiBinding: undefined },
|
||||
policy: { trustPreset: { kind: "standard", preset: "standard", boundary: null, sourcePresets: {} }, useIsolatedTaskDirectory: false, explicitTaskDirectory, requestedExecutionWorkspaceMode },
|
||||
environment: { selectedEnvironmentId: null, selectedEnvironmentForConfig: null,
|
||||
environment: { selectedEnvironmentId: selectedEnvironmentForConfig?.id ?? null, selectedEnvironmentForConfig,
|
||||
resolvedInstanceSettings, persistedNativeExecutionInput: null },
|
||||
services: { secretsSvc: secretService(db), companySkills: companySkillService(db), appendRunEvent: vi.fn(), finalizeAiConnectionBusyDeferral: vi.fn() },
|
||||
services: { secretsSvc: secretService(db), companySkills: companySkillService(db), appendRunEvent, finalizeAiConnectionBusyDeferral: vi.fn() },
|
||||
onFailureSecretsResolved: () => {}, onManagedAiRuntimeAcquired: () => {}, onIdentityRedactorPrepared: () => {},
|
||||
}) };
|
||||
}
|
||||
|
||||
it("does not install new environment declarations when recovering a saved skill selection", async () => {
|
||||
const f = await seed(), pluginId = randomUUID(), pluginKey = `fixture.${randomUUID()}`;
|
||||
await db.insert(plugins).values({ id: pluginId, pluginKey, packageName: "fixture", version: "1.0.0", status: "ready",
|
||||
manifestJson: { id: pluginKey, apiVersion: 1, version: "1.0.0", displayName: "Fixture", description: "Fixture", author: "Tests", categories: ["automation"],
|
||||
capabilities: ["environment.drivers.register", "skills.managed"], entrypoints: { worker: "worker.js" },
|
||||
environmentDrivers: [{ driverKey: "tasks", displayName: "Tasks", configSchema: {}, skillKeys: ["new-invalid"] }],
|
||||
skills: [{ skillKey: "new-invalid", displayName: "New invalid skill", markdown: "---\nname: new-invalid\nmetadata:\n sources:\n - kind: github-dir\n repo: fixture/skills\n---\nUnpinned." }] } });
|
||||
const [environment] = await db.insert(environments).values({ name: "Fixture", driver: "plugin", config: { pluginKey, driverKey: "tasks", driverConfig: {} } }).returning();
|
||||
const config = { paperclipSkillSync: { desiredSkills: [] } };
|
||||
const restoring = await configuration(f, config, { skillVersions: {}, skillSelectionSnapshot: true }, "paperclip_runner", false, "shared_workspace", environment);
|
||||
expect(await restoring.prepare()).toMatchObject({ prepared: true, runtimeSkillEntries: [] });
|
||||
const fresh = await configuration(f, config, {}, "paperclip_runner", false, "shared_workspace", environment);
|
||||
const rejected = await fresh.prepare().then(() => false, error => { expect(error.message).toMatch(/pinned Git commit/); return true; });
|
||||
expect(rejected).toBe(true);
|
||||
});
|
||||
|
||||
it("reports a shadowed assignment and captures only the required plugin skill", async () => {
|
||||
const f = await seed(), pluginId = randomUUID(), pluginKey = `fixture.${randomUUID()}`;
|
||||
const name = `plugin-${pluginKey.split(".").at(-1)}-desktop`;
|
||||
const existing = await companySkillService(db).createLocalSkill(f.companyId, { name, markdown: `---\nname: ${name}\n---\nCompany skill.` });
|
||||
await db.insert(plugins).values({ id: pluginId, pluginKey, packageName: "fixture", version: "1.0.0", status: "ready",
|
||||
manifestJson: { id: pluginKey, apiVersion: 1, version: "1.0.0", displayName: "Fixture", description: "Fixture", author: "Tests", categories: ["automation"],
|
||||
capabilities: ["environment.drivers.register", "skills.managed"], entrypoints: { worker: "worker.js" },
|
||||
environmentDrivers: [{ driverKey: "tasks", displayName: "Tasks", configSchema: {}, skillKeys: ["desktop"] }],
|
||||
skills: [{ skillKey: "desktop", displayName: "Desktop" }] } });
|
||||
const [environment] = await db.insert(environments).values({ name: randomUUID(), driver: "plugin", config: { pluginKey, driverKey: "tasks" } }).returning();
|
||||
await db.update(issues).set({ description: `Use [company skill](skill://${existing.id}).` }).where(eq(issues.id, f.issueId));
|
||||
const config = { paperclipSkillSync: { desiredSkills: [existing.key] } };
|
||||
const run = await configuration(f, config, {}, "paperclip_runner", false, "shared_workspace", environment);
|
||||
const result = await run.prepare();
|
||||
expect(result.prepared).toBe(true);
|
||||
if (!result.prepared) throw new Error("Configuration did not finish");
|
||||
const key = `plugin/${pluginKey.replaceAll(".", "-")}/desktop`;
|
||||
expect(result.runtimeSkillEntries.map(skill => skill.key)).toEqual([key]);
|
||||
expect(run.appendRunEvent).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ eventType: "skills.shadowed", level: "warn",
|
||||
message: expect.stringContaining(existing.key), payload: { shadowedSkills: [{ key: existing.key, shadowedBy: key }] } }));
|
||||
const [saved] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, run.run.id));
|
||||
expect(saved.contextSnapshot!.shadowedSkills).toEqual([{ key: existing.key, shadowedBy: key }]);
|
||||
expect(Object.keys(saved.contextSnapshot!.skillVersions as object)).toEqual([key]);
|
||||
const recovery = await configuration(f, config, saved.contextSnapshot!, "paperclip_runner", false, "shared_workspace", environment);
|
||||
const resumed = await recovery.prepare();
|
||||
expect(resumed.prepared).toBe(true);
|
||||
if (!resumed.prepared) throw new Error("Recovery did not finish");
|
||||
expect(resumed.runtimeSkillEntries.map(skill => skill.key)).toEqual([key]);
|
||||
expect(recovery.appendRunEvent.mock.calls.some(([, event]) => event.eventType === "skills.shadowed")).toBe(false);
|
||||
});
|
||||
it("keeps default task-directory configuration stable when the directory is reused", async () => {
|
||||
const f = await seed();
|
||||
const initial = await (await configuration(f, {}, {}, "paperclip_runner", true)).prepare();
|
||||
|
||||
@@ -328,6 +328,37 @@ describeEmbeddedPostgres("heartbeat run privacy routes", { concurrent: false },
|
||||
expect(maintenance.body.resultJson.summary).toBe("Maintenance complete");
|
||||
});
|
||||
|
||||
it("filters latest failures within the requested run window and preserves private-run redaction", async () => {
|
||||
const f = await seedFixture();
|
||||
await db.update(heartbeatRuns).set({ status: "failed", createdAt: new Date("2026-10-26T10:00:00Z") })
|
||||
.where(eq(heartbeatRuns.id, f.privateRunId));
|
||||
await db.update(heartbeatRuns).set({ createdAt: new Date("2026-10-26T09:00:00Z") })
|
||||
.where(eq(heartbeatRuns.id, f.maintenanceRunId));
|
||||
const timeoutId = randomUUID();
|
||||
await db.insert(heartbeatRuns).values({
|
||||
id: timeoutId, companyId: f.companyId, agentId: f.otherAgentId, scopeKind: "company",
|
||||
invocationSource: "timer", status: "timed_out", createdAt: new Date("2026-10-26T11:00:00Z"),
|
||||
});
|
||||
const app = createApp(f.companyId, f.otherAgentId);
|
||||
const path = `/api/companies/${f.companyId}/heartbeat-runs?summary=true&latestFailuresOnly=true`;
|
||||
const response = await request(app).get(path).expect(200);
|
||||
expect(response.body.map((run: { id: string }) => run.id)).toEqual([timeoutId, f.privateRunId]);
|
||||
const privateStub = response.body[1];
|
||||
expect(privateStub).toMatchObject({ redacted: true, status: "failed" });
|
||||
expect(privateStub).not.toHaveProperty("issueId");
|
||||
expect(privateStub).not.toHaveProperty("contextSnapshot");
|
||||
expect(JSON.stringify(response.body)).not.toContain("Confidential email triage");
|
||||
const bounded = await request(app).get(`${path}&limit=1`).expect(200);
|
||||
expect(bounded.body.map((run: { id: string }) => run.id)).toEqual([timeoutId]);
|
||||
const agentOnly = await request(app).get(`${path}&agentId=${f.ownerAgentId}`).expect(200);
|
||||
expect(agentOnly.body.map((run: { id: string }) => run.id)).toEqual([f.privateRunId]);
|
||||
// A newer success suppresses an older failure rather than selecting the latest failure alone.
|
||||
await db.update(heartbeatRuns).set({ createdAt: new Date("2026-10-26T12:00:00Z") })
|
||||
.where(eq(heartbeatRuns.id, f.maintenanceRunId));
|
||||
const recovered = await request(app).get(path).expect(200);
|
||||
expect(recovered.body.map((run: { id: string }) => run.id)).toEqual([timeoutId]);
|
||||
});
|
||||
|
||||
it("returns full private-run content to the owner and an explicitly granted agent", async () => {
|
||||
const fixture = await seedFixture();
|
||||
|
||||
|
||||
@@ -11,6 +11,9 @@ import {
|
||||
heartbeatRuns,
|
||||
inboxDismissals,
|
||||
invites,
|
||||
issues,
|
||||
issueComments,
|
||||
issueReadStates,
|
||||
joinRequests,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
@@ -22,6 +25,7 @@ import { inboxDismissalRoutes } from "../routes/inbox-dismissals.js";
|
||||
import { inboxDismissalService } from "../services/inbox-dismissals.ts";
|
||||
import { sidebarBadgeRoutes } from "../routes/sidebar-badges.js";
|
||||
import { sidebarBadgeService } from "../services/sidebar-badges.ts";
|
||||
import { LOW_TRUST_REVIEW_PRESET } from "@paperclipai/shared";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
@@ -46,6 +50,9 @@ describeEmbeddedPostgres("inbox dismissals", () => {
|
||||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(issueReadStates);
|
||||
await db.delete(issueComments);
|
||||
await db.delete(issues);
|
||||
await db.delete(inboxDismissals);
|
||||
await db.delete(joinRequests);
|
||||
await db.delete(invites);
|
||||
@@ -100,6 +107,49 @@ describeEmbeddedPostgres("inbox dismissals", () => {
|
||||
expect(response.body.inbox).toBe(expected);
|
||||
});
|
||||
|
||||
it("counts only unread Mine tasks without returning their bodies", async () => {
|
||||
const companyId = randomUUID(), issueId = randomUUID(), otherId = randomUUID();
|
||||
await db.insert(companies).values({ id: companyId, name: "Badge fixture", issuePrefix: "BADGE" });
|
||||
const createdAt = new Date("2026-03-11T01:00:00Z");
|
||||
const replyAt = new Date("2026-03-11T02:00:00Z");
|
||||
await db.insert(issues).values([
|
||||
{ id: issueId, companyId, title: "Mine", description: "Large body".repeat(10000), createdByUserId: "local-board", createdAt },
|
||||
{ id: otherId, companyId, title: "Not mine", createdByUserId: "someone-else", createdAt },
|
||||
]);
|
||||
await db.insert(issueComments).values([issueId, otherId].map((id) => ({ companyId, issueId: id, authorType: "user" as const, authorUserId: "someone-else", body: "Reply", createdAt: replyAt })));
|
||||
const app = express();
|
||||
app.use((req, _res, next) => {
|
||||
req.actor = { type: "board", source: "local_implicit", userId: "local-board", companyIds: [companyId], isInstanceAdmin: true };
|
||||
next();
|
||||
});
|
||||
app.use("/api", sidebarBadgeRoutes(db));
|
||||
app.use(errorHandler);
|
||||
const response = await request(app).get(`/api/companies/${companyId}/sidebar-badges`).expect(200);
|
||||
expect(response.body).toMatchObject({ inbox: 1, unreadIssueIds: [issueId] });
|
||||
expect(JSON.stringify(response.body).length).toBeLessThan(300);
|
||||
const agentId = randomUUID();
|
||||
await db.insert(agents).values({
|
||||
id: agentId, companyId, name: "Restricted reviewer", role: "engineer",
|
||||
permissions: {
|
||||
trustPreset: LOW_TRUST_REVIEW_PRESET,
|
||||
authorizationPolicy: { trustBoundary: { mode: LOW_TRUST_REVIEW_PRESET, rootIssueId: otherId, projectIds: [] } },
|
||||
},
|
||||
});
|
||||
const agentApp = express();
|
||||
agentApp.use((req, _res, next) => {
|
||||
req.actor = { type: "agent", source: "agent_jwt", agentId, companyId, onBehalfOfUserId: "local-board",
|
||||
onBehalfOfMemberships: [{ companyId, membershipRole: "member", status: "active" }] };
|
||||
next();
|
||||
});
|
||||
agentApp.use("/api", sidebarBadgeRoutes(db));
|
||||
agentApp.use(errorHandler);
|
||||
const agentResponse = await request(agentApp).get(`/api/companies/${companyId}/sidebar-badges`).expect(200);
|
||||
expect(agentResponse.body).toMatchObject({ inbox: 0, unreadIssueIds: [] });
|
||||
await db.insert(issueReadStates).values({ companyId, issueId, userId: "local-board", lastReadAt: replyAt });
|
||||
const read = await request(app).get(`/api/companies/${companyId}/sidebar-badges`).expect(200);
|
||||
expect(read.body).toMatchObject({ inbox: 0, unreadIssueIds: [] });
|
||||
});
|
||||
|
||||
it("upserts a single dismissal record per user and inbox item key", async () => {
|
||||
const companyId = randomUUID();
|
||||
const userId = "board-user";
|
||||
|
||||
@@ -97,6 +97,80 @@ describeEmbeddedPostgres("issue list routes assigneeAgentId filter", () => {
|
||||
});
|
||||
}
|
||||
|
||||
it.each(["index", "rows"])("revalidates %s rows immediately after a mutation", async (view) => {
|
||||
const companyId = randomUUID(), issueId = randomUUID();
|
||||
await db.insert(companies).values({ id: companyId, name: "Fresh list", issuePrefix: uniqueIssuePrefix() });
|
||||
await seedCloudTenantMember(companyId);
|
||||
await db.insert(issues).values({ id: issueId, companyId, title: "Before" });
|
||||
const app = createApp(companyId);
|
||||
const before = await request(app).get(`/api/companies/${companyId}/issues`).query({ view, ids: issueId }).expect(200);
|
||||
await db.update(issues).set({ title: "After" }).where(eq(issues.id, issueId));
|
||||
const after = await request(app).get(`/api/companies/${companyId}/issues`).query({ view, ids: issueId })
|
||||
.set("If-None-Match", before.headers.etag).expect(200);
|
||||
expect(after.body[0].title).toBe("After");
|
||||
expect(after.headers.etag).not.toBe(before.headers.etag);
|
||||
expect(after.headers["x-paperclip-request-cache"]).toBe("miss");
|
||||
});
|
||||
|
||||
it("keeps index and row batches scoped to readable tasks and validates their bounds", async () => {
|
||||
vi.stubEnv("PAPERCLIP_ISSUE_PRIVACY_MODE", "enforce");
|
||||
try {
|
||||
const companyId = randomUUID(), foreignCompanyId = randomUUID();
|
||||
const parentId = randomUUID(), childId = randomUUID(), privateId = randomUUID(), foreignId = randomUUID();
|
||||
await db.insert(companies).values([
|
||||
{ id: companyId, name: "Lists", issuePrefix: uniqueIssuePrefix() },
|
||||
{ id: foreignCompanyId, name: "Other", issuePrefix: uniqueIssuePrefix() },
|
||||
]);
|
||||
await seedCloudTenantMember(companyId);
|
||||
await db.insert(issues).values([
|
||||
{ id: parentId, companyId, title: "Public parent" },
|
||||
{ id: childId, companyId, parentId, title: "Public child" },
|
||||
{ id: privateId, companyId, parentId, title: "Private child", visibility: "private", privacyRootIssueId: privateId, createdByUserId: "someone-else" },
|
||||
{ id: foreignId, companyId: foreignCompanyId, title: "Foreign task" },
|
||||
]);
|
||||
const app = createApp(companyId);
|
||||
for (const view of ["index", "rows"]) {
|
||||
const selected = await request(app).get(`/api/companies/${companyId}/issues`).query({ view, ids: [parentId, privateId, foreignId].join(",") });
|
||||
expect(selected.status, JSON.stringify(selected.body)).toBe(200);
|
||||
expect(selected.body.map((row: { id: string }) => row.id)).toEqual([parentId]);
|
||||
const children = await request(app).get(`/api/companies/${companyId}/issues`).query({ view, parentIds: parentId });
|
||||
expect(children.status).toBe(200);
|
||||
expect(children.body.map((row: { id: string }) => row.id)).toEqual([childId]);
|
||||
}
|
||||
const preview = await request(app).get(`/api/companies/${companyId}/issues`).query({ view: "index", parentIds: parentId, oneChildPerParent: true }).expect(200);
|
||||
expect(preview.body.map((row: { id: string }) => row.id)).toEqual([childId]);
|
||||
for (const query of [
|
||||
{ view: "index", ids: "invalid" },
|
||||
{ view: "index", parentIds: Array.from({ length: 101 }, () => randomUUID()).join(",") },
|
||||
{ view: "index", ids: parentId, attention: "blocked" },
|
||||
{ view: "compact", ids: parentId },
|
||||
{ view: "index", oneChildPerParent: true },
|
||||
{ view: "rows", parentIds: parentId, oneChildPerParent: true },
|
||||
{ view: "index", parentIds: parentId, oneChildPerParent: "invalid" },
|
||||
]) {
|
||||
expect((await request(app).get(`/api/companies/${companyId}/issues`).query(query)).status).toBe(400);
|
||||
}
|
||||
} finally { vi.unstubAllEnvs(); }
|
||||
});
|
||||
|
||||
it("returns one child per parent without letting a large collapsed tree fill the page", async () => {
|
||||
const companyId = randomUUID(), firstParentId = randomUUID(), secondParentId = randomUUID();
|
||||
await db.insert(companies).values({ id: companyId, name: "Collapsed trees", issuePrefix: uniqueIssuePrefix() });
|
||||
await seedCloudTenantMember(companyId);
|
||||
await db.insert(issues).values([
|
||||
{ id: firstParentId, companyId, title: "First parent" },
|
||||
{ id: secondParentId, companyId, title: "Second parent" },
|
||||
]);
|
||||
await db.insert(issues).values([
|
||||
...Array.from({ length: 120 }, (_, index) => ({ companyId, parentId: firstParentId, title: `Child ${index}` })),
|
||||
{ companyId, parentId: secondParentId, title: "Other child" },
|
||||
]);
|
||||
const response = await request(createApp(companyId)).get(`/api/companies/${companyId}/issues`)
|
||||
.query({ view: "index", parentIds: [firstParentId, secondParentId].join(","), oneChildPerParent: true, limit: 2 }).expect(200);
|
||||
expect(response.body).toHaveLength(2);
|
||||
expect(new Set(response.body.map((row: { parentId: string }) => row.parentId))).toEqual(new Set([firstParentId, secondParentId]));
|
||||
});
|
||||
|
||||
it("returns only unassigned issues for assigneeAgentId=null", async () => {
|
||||
const companyId = randomUUID();
|
||||
const assigneeAgentId = randomUUID();
|
||||
@@ -240,6 +314,15 @@ describeEmbeddedPostgres("issue list routes assigneeAgentId filter", () => {
|
||||
assigneeAgentId: ownerAgentId,
|
||||
},
|
||||
});
|
||||
const index = await request(app).get(`/api/companies/${companyId}/issues`).query({ view: "index", limit: "20" });
|
||||
expect(index.status).toBe(200);
|
||||
expect(index.body).toHaveLength(1);
|
||||
expect(index.body[0]).toMatchObject({ id: issueId, listProjection: "index", description: null, activeRecoveryAction: null, successfulRunHandoff: null });
|
||||
expect(index.headers.etag).not.toBe(res.headers.etag);
|
||||
const detailRows = await request(app).get(`/api/companies/${companyId}/issues`).query({ view: "rows", ids: issueId });
|
||||
expect(detailRows.status).toBe(200);
|
||||
expect(detailRows.body[0]).toMatchObject({ id: issueId, listProjection: "rows", description: null, activeRecoveryAction: { id: recoveryAction.id } });
|
||||
|
||||
expect(res.body[0]).not.toHaveProperty("workProducts");
|
||||
expect(res.body[0]).not.toHaveProperty("project");
|
||||
expect(res.body[0]).not.toHaveProperty("goal");
|
||||
@@ -800,6 +883,7 @@ describeEmbeddedPostgres("issue list routes assigneeAgentId filter", () => {
|
||||
"descendantOf",
|
||||
"labelId",
|
||||
"participantAgentId",
|
||||
"createdFromIssueId",
|
||||
])("returns 422 instead of a DB error for malformed %s filters", async (param) => {
|
||||
const companyId = randomUUID();
|
||||
await db.insert(companies).values({
|
||||
@@ -828,6 +912,7 @@ describeEmbeddedPostgres("issue list routes assigneeAgentId filter", () => {
|
||||
"descendantOf",
|
||||
"labelId",
|
||||
"participantAgentId",
|
||||
"createdFromIssueId",
|
||||
])("returns 422 from issues/count for malformed %s filters", async (param) => {
|
||||
const companyId = randomUUID();
|
||||
await db.insert(companies).values({
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
import { companySkillService } from "../services/company-skills.js";
|
||||
import { buildHostServices } from "../services/plugin-host-services.js";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
@@ -119,7 +120,8 @@ describeEmbeddedPostgres("plugin-managed skills", () => {
|
||||
|
||||
expect(created.status).toBe("created");
|
||||
expect(created.skill).toMatchObject({
|
||||
name: "Wiki Maintainer Skill",
|
||||
name: "plugin-managed-skills-test-wiki-maintainer",
|
||||
slug: "plugin-managed-skills-test-wiki-maintainer",
|
||||
key: "plugin/paperclip-managed-skills-test/wiki-maintainer",
|
||||
sourceType: "catalog",
|
||||
fileInventory: expect.arrayContaining([
|
||||
@@ -144,6 +146,35 @@ describeEmbeddedPostgres("plugin-managed skills", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a same-slug user skill separate through automatic install, reset, and relinking", async () => {
|
||||
const { companyId, services } = await seedCompanyAndPlugin();
|
||||
const original = await companySkillService(db).createLocalSkill(companyId, { name: "Wiki Maintainer Skill", markdown: "# User instructions\n" });
|
||||
const created = await services.skills.managedReconcile({ companyId, skillKey: "wiki-maintainer" });
|
||||
expect(created.skillId).not.toBe(original.id);
|
||||
expect(created.skill!.key).toBe("plugin/paperclip-managed-skills-test/wiki-maintainer");
|
||||
expect(created.skill!.slug).toBe("plugin-managed-skills-test-wiki-maintainer");
|
||||
expect(created.skill!.markdown).toContain('name: "plugin-managed-skills-test-wiki-maintainer"');
|
||||
await services.skills.managedReset({ companyId, skillKey: "wiki-maintainer" });
|
||||
await db.delete(pluginManagedResources).where(eq(pluginManagedResources.resourceId, created.skillId!));
|
||||
const relinked = await services.skills.managedReconcile({ companyId, skillKey: "wiki-maintainer" });
|
||||
expect(relinked.skillId).toBe(created.skillId);
|
||||
expect((await db.select().from(companySkills).where(eq(companySkills.id, original.id)))[0]).toMatchObject({ key: original.key, slug: original.slug, markdown: original.markdown });
|
||||
});
|
||||
|
||||
it("claims the plugin name while preserving a conflicting company skill as a separate record", async () => {
|
||||
const { companyId, services } = await seedCompanyAndPlugin();
|
||||
const original = await companySkillService(db).createLocalSkill(companyId, {
|
||||
name: "plugin-managed-skills-test-wiki-maintainer", markdown: "# User instructions\n",
|
||||
});
|
||||
const installed = await services.skills.managedReconcile({ companyId, skillKey: "wiki-maintainer" });
|
||||
expect(installed.skill!.slug).toBe(original.slug);
|
||||
expect(installed.skill!.id).not.toBe(original.id);
|
||||
await services.skills.managedReset({ companyId, skillKey: "wiki-maintainer" });
|
||||
const records = (await db.select().from(companySkills)).filter(skill => [original.key, installed.skill!.key].includes(skill.key));
|
||||
expect(records).toHaveLength(2);
|
||||
expect(records.find(skill => skill.id === original.id)).toMatchObject({ key: original.key, markdown: original.markdown });
|
||||
expect(await db.select().from(pluginManagedResources)).toEqual([expect.objectContaining({ resourceId: installed.skill!.id })]);
|
||||
});
|
||||
it("preserves operator edits during reconcile and restores manifest defaults on reset", async () => {
|
||||
const { companyId, services } = await seedCompanyAndPlugin();
|
||||
const created = await services.skills.managedReconcile({ companyId, skillKey: "wiki-maintainer" });
|
||||
@@ -168,7 +199,7 @@ describeEmbeddedPostgres("plugin-managed skills", () => {
|
||||
const reset = await services.skills.managedReset({ companyId, skillKey: "wiki-maintainer" });
|
||||
expect(reset.status).toBe("reset");
|
||||
expect(reset.skill).toMatchObject({
|
||||
name: "Wiki Maintainer Skill",
|
||||
name: "plugin-managed-skills-test-wiki-maintainer",
|
||||
description: "Use LLM Wiki tools to maintain company knowledge.",
|
||||
});
|
||||
expect(reset.skill?.markdown).toContain("key: \"plugin/paperclip-managed-skills-test/wiki-maintainer\"");
|
||||
@@ -248,6 +279,18 @@ describeEmbeddedPostgres("plugin-managed skills", () => {
|
||||
expect(reset.defaultDrift).toBeNull();
|
||||
});
|
||||
|
||||
it("preserves unrelated YAML while replacing quoted and multiline identity fields", async () => {
|
||||
const declaration = manifest();
|
||||
declaration.skills = [{ skillKey: "yaml-skill", displayName: "YAML skill", markdown:
|
||||
'---\n"name": |\n Previous name\nslug: old-slug\ndescription: >\n Keep this folded description.\nmetadata:\n "category": desktop\n# Keep this comment.\n---\nBody stays unchanged.\n' }];
|
||||
const { companyId, services } = await seedCompanyAndPlugin(declaration);
|
||||
const created = await services.skills.managedReconcile({ companyId, skillKey: "yaml-skill" });
|
||||
expect(created.skill!.markdown).toContain('name: "plugin-managed-skills-test-yaml-skill"');
|
||||
expect(created.skill!.markdown).not.toContain("Previous name");
|
||||
expect(created.skill!.markdown).toContain('description: >\n Keep this folded description.\nmetadata:\n "category": desktop\n# Keep this comment.');
|
||||
expect(created.skill!.markdown).toMatch(/Body stays unchanged\.\n$/);
|
||||
await services.skills.managedReset({ companyId, skillKey: "yaml-skill" });
|
||||
});
|
||||
it("adds the canonical managed key to manifest-provided markdown skills", async () => {
|
||||
const pluginManifest = manifest();
|
||||
pluginManifest.skills = [
|
||||
@@ -274,8 +317,8 @@ describeEmbeddedPostgres("plugin-managed skills", () => {
|
||||
expect(created.status).toBe("created");
|
||||
expect(created.skill).toMatchObject({
|
||||
key: "plugin/paperclip-managed-skills-test/markdown-skill",
|
||||
name: "markdown-skill",
|
||||
name: "plugin-managed-skills-test-markdown-skill",
|
||||
});
|
||||
expect(created.skill?.markdown).toContain("key: \"plugin/paperclip-managed-skills-test/markdown-skill\"");
|
||||
expect(created.skill?.markdown).toContain('key: "plugin/paperclip-managed-skills-test/markdown-skill"');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user