mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 12:37:55 +01:00
test(evals): normalize disposable team fixture runtime
Preserve the shipped team graph and non-runtime sidecar fields while selecting the campaign OpenCode account through the catalog package. Verify the actual no-overrides installer without model execution. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
c9e3418fef
commit
5dc8314e39
6 files changed
+136
-4
No files matched your search
@@ -149,4 +149,35 @@ describeEmbeddedPostgres("teams catalog install with no caller adapter overrides
|
||||
.map((row) => row.adapterType);
|
||||
expect(otherAdapters).toEqual(["paperclip_runner", "paperclip_runner"]);
|
||||
});
|
||||
it("derived catalog installs selected native runtime without caller overrides", async () => {
|
||||
const repositoryRoot = path.resolve(import.meta.dirname, "../../..");
|
||||
const temporaryRoot = await fs.mkdtemp(path.join(os.tmpdir(), "team-runtime-derived-pg-"));
|
||||
const oldDir = process.env.PAPERCLIP_TEAMS_CATALOG_DIR, oldDefault = process.env.PAPERCLIP_TEAMS_CATALOG_DEFAULT_ADAPTER_TYPE;
|
||||
try {
|
||||
const helper = await import("../../../tests/runner-e2e/chat-catalog-domains/external-team-runtime-fixture.js");
|
||||
Object.assign(process.env, await helper.teamRuntimeCatalogServerEnvironment({ repositoryRoot, temporaryRoot, executionIds: ["chat-features.runner-opencode.local.teams.install-team"] }));
|
||||
const companyId = await seedEmptyCompany();
|
||||
const { secretService } = await import("../services/secrets.js");
|
||||
const account = await secretService(db).create(companyId, { name: "Derived package account", provider: "local_encrypted", value: "synthetic-model-free-account" });
|
||||
const manifest = JSON.parse(await fs.readFile(path.join(repositoryRoot, "packages/teams-catalog/generated/catalog.json"), "utf8"));
|
||||
const team = manifest.teams.find((row: {slug: string}) => row.slug === "product-engineering");
|
||||
const config = { provider: "opencode", model: "openrouter/deepseek/deepseek-v4.1-flash-1017", timeoutSec: 0, lifecycleMode: "per_turn", opencodePermissionMode: "allow", env: { OPENROUTER_API_KEY: { type: "secret_ref", secretId: account.id, version: "latest" } } };
|
||||
const derived = await helper.normalizeTeamRuntimeCatalog({ repositoryRoot, temporaryRoot, team, adapters: Object.fromEntries(team.agentSlugs.map((slug: string) => [slug, { adapterType: "paperclip_runner", adapterConfig: config }])) });
|
||||
vi.resetModules();
|
||||
const { teamsCatalogService: actualService, getCatalogTeamOrThrow } = await import("../services/teams-catalog.js");
|
||||
expect((await getCatalogTeamOrThrow(team.id)).contentHash).toBe(derived.effectiveContentHash);
|
||||
const svc = actualService(db);
|
||||
const preview = await svc.previewCatalogTeamImport(companyId, team.id, {});
|
||||
for (const member of preview.portabilityPreview.manifest.agents) expect(member.adapterConfig).toMatchObject(config);
|
||||
await svc.installCatalogTeam(companyId, team.id, { collisionStrategy: "rename", include: { projects: false, issues: false } });
|
||||
const saved = await db.select().from(agents).where(eq(agents.companyId, companyId));
|
||||
expect(saved).toHaveLength(3);
|
||||
for (const member of saved) { expect(member.adapterType).toBe("paperclip_runner"); expect(member.adapterConfig).toMatchObject(config); }
|
||||
} finally {
|
||||
if (oldDir === undefined) delete process.env.PAPERCLIP_TEAMS_CATALOG_DIR; else process.env.PAPERCLIP_TEAMS_CATALOG_DIR = oldDir;
|
||||
if (oldDefault === undefined) delete process.env.PAPERCLIP_TEAMS_CATALOG_DEFAULT_ADAPTER_TYPE; else process.env.PAPERCLIP_TEAMS_CATALOG_DEFAULT_ADAPTER_TYPE = oldDefault;
|
||||
await fs.rm(temporaryRoot, { recursive: true, force: true });
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
});
|
||||
@@ -6024,6 +6024,9 @@
|
||||
"sourceFiles": [
|
||||
"chat-catalog-domains/external-team-install.ts",
|
||||
"chat-catalog-domains/external-team-conditional.ts",
|
||||
"chat-catalog-domains/external-team-runtime-fixture.ts",
|
||||
"server.ts",
|
||||
"../../packages/teams-catalog/src/catalog-builder.ts",
|
||||
"../../server/src/services/teams-catalog.ts",
|
||||
"../../server/src/services/company-skills.ts",
|
||||
"../../server/src/services/company-portability.ts",
|
||||
@@ -6034,7 +6037,7 @@
|
||||
"chat-flow.ts",
|
||||
"chat-scenarios/teams.yaml"
|
||||
],
|
||||
"oracleVersion": "paperclip.chat-catalog.team-install.v6",
|
||||
"oracleVersion": "paperclip.chat-catalog.team-install.v7",
|
||||
"expectedRunCount": 2,
|
||||
"expectedHumanMessageCount": 2,
|
||||
"turnTimeoutMs": 120000,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import path from "node:path";
|
||||
import { normalizeTeamRuntimeCatalog } from "./external-team-runtime-fixture.js";
|
||||
import type { ChatCatalogContext } from "../chat-catalog-contract.js";
|
||||
import { CHAT_OPENCODE_MODEL, CHAT_OPENCODE_PROFILE } from "../chat-runtime-profile.js";
|
||||
import type { SecretReference } from "../types.js";
|
||||
@@ -269,7 +271,7 @@ export const teamInstallExternalCases = [
|
||||
async (ctx) => {
|
||||
const teams =
|
||||
await ctx.input.api.get<CatalogTeam[]>("/api/teams/catalog");
|
||||
const team = teams.find(
|
||||
let team = teams.find(
|
||||
(row) =>
|
||||
row.slug === "product-engineering" &&
|
||||
row.compatibility === "compatible",
|
||||
@@ -300,6 +302,12 @@ export const teamInstallExternalCases = [
|
||||
);
|
||||
const selectedRuntime = selectedTeamInstallRuntime(ctx, actor);
|
||||
const adapterOverrides = teamInstallOverrides(team, selectedRuntime);
|
||||
const temporaryRoot = process.env.PAPERCLIP_RUNNER_E2E_TEMP_ROOT;
|
||||
if (!temporaryRoot) throw new Error("Team runtime fixture requires its disposable attempt root");
|
||||
const catalogRuntime = await normalizeTeamRuntimeCatalog({ temporaryRoot, repositoryRoot: path.resolve(import.meta.dirname, "../../.."), team, adapters: adapterOverrides });
|
||||
team = catalogRuntime.team;
|
||||
const served = await ctx.input.api.get<CatalogTeam[]>("/api/teams/catalog");
|
||||
if (served.find(row => row.id === team.id)?.contentHash !== catalogRuntime.effectiveContentHash) throw new Error("Server did not serve the derived runtime catalog");
|
||||
const conditional = await prepareTeamConditions(ctx, team);
|
||||
const preview = conditional.conditionalPreview;
|
||||
if (preview.errors.length || preview.portabilityPreview.errors.length)
|
||||
@@ -314,7 +322,7 @@ export const teamInstallExternalCases = [
|
||||
await ctx.input.api.post(`${root(ctx)}/chats/${actor.id}`);
|
||||
await ctx.refreshIssue();
|
||||
await ctx.input.api.patch(`/api/issues/${ctx.issue()!.id}`, {
|
||||
description: `The accepted website team preview is ${team.name}, catalog key ${team.key}, content hash ${team.contentHash}. Install the complete selected package under the current CEO using this company's existing eligible runtime account. No team resource is installed yet. The selected runtime uses ${actor.adapterType} in environment ${ctx.input.fixtures.environment.id}. The existing eligible runtime is OpenCode/OpenRouter ${selectedRuntime.model}; its credential stays in the existing same-company secret reference. The supported installer takes these per-slug adapterOverrides for this accepted runtime selection: ${JSON.stringify(adapterOverrides)}.`,
|
||||
description: `The accepted website team preview is ${team.name}, catalog key ${team.key}, content hash ${team.contentHash}. Install the complete selected package under the current CEO using this company's existing eligible runtime account. No team resource is installed yet. This disposable catalog package already selects OpenCode/OpenRouter ${selectedRuntime.model}; its credential stays in the existing same-company secret reference.`,
|
||||
});
|
||||
const session = await ctx.input.api.get<{ user: { id: string } }>("/api/auth/get-session");
|
||||
if (!session.user?.id) throw new Error("Team installer requires its actual responsible human session");
|
||||
@@ -323,6 +331,7 @@ export const teamInstallExternalCases = [
|
||||
humanId: session.user.id,
|
||||
...conditional,
|
||||
selectedRuntime,
|
||||
catalogRuntime,
|
||||
team,
|
||||
before,
|
||||
companyPrefix: ctx.input.fixtures.company.issuePrefix,
|
||||
@@ -337,13 +346,16 @@ export const teamInstallExternalCases = [
|
||||
gradeTeamInstall,
|
||||
{
|
||||
authentication: "human-session",
|
||||
oracleVersion: "paperclip.chat-catalog.team-install.v6",
|
||||
oracleVersion: "paperclip.chat-catalog.team-install.v7",
|
||||
expectedRunCount: 2,
|
||||
expectedHumanMessageCount: 2,
|
||||
attemptTimeoutMs: 480_000,
|
||||
sourceFiles: [
|
||||
"chat-catalog-domains/external-team-install.ts",
|
||||
"chat-catalog-domains/external-team-conditional.ts",
|
||||
"chat-catalog-domains/external-team-runtime-fixture.ts",
|
||||
"server.ts",
|
||||
"../../packages/teams-catalog/src/catalog-builder.ts",
|
||||
"../../server/src/services/teams-catalog.ts",
|
||||
"../../server/src/services/company-skills.ts",
|
||||
"../../server/src/services/company-portability.ts",
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { expect, it } from "vitest";
|
||||
import YAML from "yaml";
|
||||
import { normalizeTeamRuntimeCatalog, teamRuntimeCatalogRoot, teamRuntimeCatalogServerEnvironment } from "./external-team-runtime-fixture.js";
|
||||
const repositoryRoot = path.resolve(import.meta.dirname, "../../..");
|
||||
it("serves a derived runtime-only catalog while preserving shipped roles, permissions, skills and other teams", async () => {
|
||||
const temporaryRoot = await mkdtemp(path.join(os.tmpdir(), "team-runtime-fixture-"));
|
||||
try {
|
||||
expect(await teamRuntimeCatalogServerEnvironment({ executionIds: ["unrelated"], temporaryRoot, repositoryRoot })).toEqual({});
|
||||
const env = await teamRuntimeCatalogServerEnvironment({ executionIds: ["chat-features.runner-opencode.local.teams.install-team"], temporaryRoot, repositoryRoot });
|
||||
expect(env.PAPERCLIP_TEAMS_CATALOG_DEFAULT_ADAPTER_TYPE).toBe("paperclip_runner");
|
||||
const original = JSON.parse(await readFile(path.join(repositoryRoot, "packages/teams-catalog/generated/catalog.json"), "utf8"));
|
||||
const team = original.teams.find((row: { slug: string }) => row.slug === "product-engineering");
|
||||
const adapterConfig = { provider: "opencode", model: "openrouter/deepseek/deepseek-v4.1-flash-1017", timeoutSec: 0, lifecycleMode: "per_turn", opencodePermissionMode: "allow", env: { OPENROUTER_API_KEY: { type: "secret_ref", secretId: "test-account", version: "latest" } } };
|
||||
const adapters = Object.fromEntries(team.agentSlugs.map((slug: string) => [slug, { adapterType: "paperclip_runner", adapterConfig }]));
|
||||
const result = await normalizeTeamRuntimeCatalog({ repositoryRoot, temporaryRoot, team, adapters });
|
||||
expect(result.originalContentHash).toBe(team.contentHash);
|
||||
expect(result.effectiveContentHash).not.toBe(team.contentHash);
|
||||
const sidecar = YAML.parse(await readFile(path.join(teamRuntimeCatalogRoot(temporaryRoot), team.path, ".paperclip.yaml"), "utf8"));
|
||||
expect(sidecar.agents.cto.permissions).toEqual({ canCreateAgents: true });
|
||||
for (const slug of team.agentSlugs) expect(sidecar.agents[slug].adapter).toEqual({ type: "paperclip_runner", config: adapterConfig });
|
||||
expect(result.team.requiredSkills).toEqual(team.requiredSkills);
|
||||
await expect(normalizeTeamRuntimeCatalog({ repositoryRoot, temporaryRoot, team: { ...team, contentHash: "foreign" }, adapters })).rejects.toThrow("exact unmodified");
|
||||
} finally { await rm(temporaryRoot, { recursive: true, force: true }); }
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { parseFrontmatterMarkdown } from "../../../packages/shared/src/frontmatter.js";
|
||||
import YAML from "yaml";
|
||||
import { cp, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import { buildCatalogManifest } from "../../../packages/teams-catalog/src/catalog-builder.js";
|
||||
import type { CatalogTeam } from "../../../packages/shared/src/types/teams-catalog.js";
|
||||
import { reviewedChatExecutionIds } from "../chat-runtime-profile.js";
|
||||
|
||||
const selectedIds = reviewedChatExecutionIds("teams.install-team");
|
||||
export const teamRuntimeCatalogRoot = (temporaryRoot: string) => path.join(temporaryRoot, "team-runtime-catalog");
|
||||
|
||||
/** Only the disposable installer fixture uses this catalog and native default. */
|
||||
export async function teamRuntimeCatalogServerEnvironment(input: { executionIds: string[]; temporaryRoot: string; repositoryRoot: string }) {
|
||||
if (!input.executionIds.some(id => selectedIds.includes(id))) return {};
|
||||
if (input.executionIds.length !== 1) throw new Error("Team runtime catalog requires its isolated execution");
|
||||
const source = path.join(input.repositoryRoot, "packages/teams-catalog"), target = teamRuntimeCatalogRoot(input.temporaryRoot);
|
||||
await mkdir(target, { recursive: true });
|
||||
for (const entry of ["package.json", "catalog", "generated"]) await cp(path.join(source, entry), path.join(target, entry), { recursive: true, force: false, errorOnExist: true });
|
||||
return { PAPERCLIP_TEAMS_CATALOG_DIR: target, PAPERCLIP_TEAMS_CATALOG_DEFAULT_ADAPTER_TYPE: "paperclip_runner" };
|
||||
}
|
||||
|
||||
/** Add runtime/account metadata only; all shipped team files remain byte-identical. */
|
||||
export async function normalizeTeamRuntimeCatalog(input: { temporaryRoot: string; repositoryRoot: string; team: CatalogTeam; adapters: Record<string, { adapterType: string; adapterConfig: Record<string, unknown> }> }) {
|
||||
const packageDir = teamRuntimeCatalogRoot(input.temporaryRoot);
|
||||
const original = JSON.parse(await readFile(path.join(input.repositoryRoot, "packages/teams-catalog/generated/catalog.json"), "utf8"));
|
||||
const source = original.teams.find((team: CatalogTeam) => team.id === input.team.id) as CatalogTeam | undefined;
|
||||
if (!source || source.contentHash !== input.team.contentHash || !isDeepStrictEqual(Object.keys(input.adapters).sort(), [...source.agentSlugs].sort())) throw new Error("Team runtime fixture must start from the exact unmodified accepted package");
|
||||
for (const file of source.files) {
|
||||
const shipped = await readFile(path.join(input.repositoryRoot, "packages/teams-catalog", source.path, file.path));
|
||||
const derived = await readFile(path.join(packageDir, source.path, file.path));
|
||||
if (!shipped.equals(derived)) throw new Error("Team runtime fixture changed a shipped non-runtime file");
|
||||
}
|
||||
const sidecarPath = path.join(packageDir, source.path, ".paperclip.yaml");
|
||||
const originalSidecar = YAML.parse(await readFile(sidecarPath, "utf8"));
|
||||
const sidecar = structuredClone(originalSidecar);
|
||||
sidecar.agents ??= {};
|
||||
for (const [slug, adapter] of Object.entries(input.adapters)) sidecar.agents[slug] = { ...sidecar.agents[slug], adapter: { type: adapter.adapterType, config: adapter.adapterConfig } };
|
||||
const serialized = YAML.stringify(sidecar, { aliasDuplicateObjects: false, lineWidth: 0 });
|
||||
if (!isDeepStrictEqual(parseFrontmatterMarkdown(`---\n${serialized}---\n`).frontmatter, sidecar)) throw new Error("Runtime sidecar is not supported by the actual portability parser");
|
||||
await writeFile(sidecarPath, serialized, { mode: 0o600 });
|
||||
const restored = structuredClone(sidecar);
|
||||
for (const slug of Object.keys(input.adapters)) {
|
||||
delete restored.agents[slug].adapter;
|
||||
if (!Object.keys(restored.agents[slug]).length) delete restored.agents[slug];
|
||||
}
|
||||
if (!isDeepStrictEqual(restored, originalSidecar)) throw new Error("Team runtime fixture changed non-runtime sidecar fields");
|
||||
const skills = JSON.parse(await readFile(path.join(input.repositoryRoot, "packages/skills-catalog/generated/catalog.json"), "utf8"));
|
||||
const built = await buildCatalogManifest({ packageDir, generatedAt: original.generatedAt, catalogSkills: skills.skills });
|
||||
if (built.errors.length) throw new Error(`Derived runtime catalog is invalid: ${built.errors.join("; ")}`);
|
||||
const effective = built.manifest.teams.find(team => team.id === source.id)!;
|
||||
const withoutRuntime = (team: CatalogTeam) => ({ ...team, contentHash: "", fileCount: 0, files: team.files.filter(file => file.path !== ".paperclip.yaml") });
|
||||
if (!isDeepStrictEqual(withoutRuntime(effective), withoutRuntime(source)) || effective.contentHash === source.contentHash || built.manifest.teams.some(team => team.id !== source.id && !isDeepStrictEqual(team, original.teams.find((row: CatalogTeam) => row.id === team.id)))) throw new Error("Derived catalog changed non-runtime team metadata");
|
||||
await writeFile(path.join(packageDir, "generated/catalog.json"), JSON.stringify(built.manifest, null, 2) + "\n");
|
||||
return { originalContentHash: source.contentHash, effectiveContentHash: effective.contentHash, originalFilesUnchanged: true, team: effective };
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { teamRuntimeCatalogServerEnvironment } from "./chat-catalog-domains/external-team-runtime-fixture.js";
|
||||
import { stopSetupHealthIfRequested } from "./chat-setup-health.js";
|
||||
import { publishInvitationSourceProof, invitationSourceProofPath } from "./chat-catalog-domains/identity-invite-source-proof.js";
|
||||
import { readSelectedVaultServerEnvironment } from "./chat-vault-private-server.js";
|
||||
@@ -72,6 +73,7 @@ const publicOriginReceipt = process.env[PUBLIC_ORIGIN_RECEIPT_ENV] ? await readV
|
||||
if (publicOriginReceipt && process.env.PAPERCLIP_RUNNER_E2E_PUBLIC_MCP !== "1") throw new Error("Owned public origin requires the exact authenticated selected case");
|
||||
for (const key of [PUBLIC_ORIGIN_RECEIPT_ENV, PUBLIC_ORIGIN_CONTROL_ENV, "PAPERCLIP_PUBLIC_URL", "PAPERCLIP_AUTH_PUBLIC_BASE_URL", "PAPERCLIP_MANAGED_RUNTIME_PUBLIC_URL", "PAPERCLIP_ALLOWED_HOSTNAMES"]) delete archiveEnvironment.source[key];
|
||||
const vaultEnvironment = await readSelectedVaultServerEnvironment({ executionIds, temporaryRoot, backendOrigin: `http://127.0.0.1:${port}`, env: process.env });
|
||||
const teamCatalogEnvironment = await teamRuntimeCatalogServerEnvironment({ executionIds, temporaryRoot, repositoryRoot });
|
||||
const serverEnvironment = buildPaperclipServerEnvironment(installedRelease
|
||||
? installedReleaseEnvironment(archiveEnvironment.source, repositoryRoot, path.join(temporaryRoot, "provider-bin"))
|
||||
: archiveEnvironment.source, {
|
||||
@@ -117,6 +119,7 @@ const serverEnvironment = buildPaperclipServerEnvironment(installedRelease
|
||||
// to Playwright in this harness, so never create a developer desktop tab.
|
||||
PAPERCLIP_OPEN_ON_LISTEN: "false",
|
||||
...vaultEnvironment,
|
||||
...teamCatalogEnvironment,
|
||||
});
|
||||
assertIsolatedServerEnvironment(serverEnvironment, {
|
||||
temporaryRoot,
|
||||
|
||||
Reference in new issue
Block a user