mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-30 05:16:50 +01:00
## Thinking Path > - Paperclip manages AI agents and their work. > - Pipelines was an unused experiment with its own UI, API, CLI, and automation. > - Keeping it leaves unsupported product surfaces and extra task-detail queries. > - Removing the experiment must preserve data that users already saved. > - This pull request removes the feature and keeps its database schema and migration history. > - Cases and Routines remain available as separate features. ## Linked Issues or Issue Description **What existing behavior does this improve?** Remove the unused Pipelines experiment across the application. **Current behavior** The experiment includes pipeline boards, stage settings, case automation, review queues, learnings, permissions, and CLI commands. Task detail also queries pipeline links. **Proposed behavior** Remove all these product surfaces and their implementation. Preserve existing tables, records, and documents. Ignore the retired boolean in older managed configurations so upgrades still start. **Breaking changes** Pipeline URLs, API endpoints, CLI commands, and `pipelines:write` are removed. Task detail no longer includes the old `linkedCases` field. The independent Cases API remains available. No database migration or cleanup is included. Related feature work: #7907, #9110, #10905, #11375, #13004, #14671, #16133. Related issues: #9663, #10310, #10312. This removal supersedes the feature; it does not implement those pending enhancements or delete their saved data. ## What Changed - Remove Pipeline UI pages, navigation, gates, clients, query keys, helpers, and unused design tokens. - Remove Pipeline REST routes, services, automation dispatch, task-detail queries, and OpenAPI discovery. - Remove the CLI commands, shared contracts, experimental flag, catalog entry, and permission defaults. - Remove feature-specific tests, tutorials, smoke scripts, maps, and Storybook examples. - Preserve database schema, migration history, and the reserved document key used by old records. - Keep Cases routes independent and cover obsolete stored and managed settings. ## Verification - Passed: `pnpm build` and `pnpm -r typecheck`. - Passed: targeted settings, managed configuration, sidebar, settings UI, routing, and feature-catalog tests. A first sidebar run caught a shared Workspaces icon import; the import was restored and the sidebar rerun passed. - Passed: 40 tests covering Cases routes, OpenAPI discovery, retained Pipeline table constraints, and schema snapshot drift. - Passed: token gates, map validation, generated API metadata check, Storybook catalog validation, and 28 map/catalog tests. - Passed: isolated local browser verification of navigation and Experimental settings with Pipelines absent; Cases and Routines still open normally. Live GET/POST requests to retired Pipeline endpoints return 404, and the retired setting is absent. CLI help has no Pipeline commands. - Passed: 22 access-service and agent-default-grant tests after restoring unrelated permission assertions flagged in review. - Local full run: `pnpm test:run` reported one failure in the unchanged workspace-runtime port-allocation test. That test passed in an isolated rerun. The long local run was stopped after full CI passed; it is not reported as a complete local pass. - Passed: all 56 checks on `def9181f3a4491d95fca1d38e9739f5b821aae3e` (2 conditional checks skipped), including build, typecheck, test shards, and browser suites. Eleven interrupted CI jobs reported “The operation was canceled”; their one-time retry passed. [CI run](https://github.com/paperclipai/paperclip/actions/runs/39255211006). - Greptile: 5/5 on the final commit, with the only review finding fixed and resolved. - Sandbox socket restrictions affected the first build/API test attempts. The same commands ran with local sockets available. ## Risks - Existing Pipeline clients must stop using the removed endpoints and commands. - Existing data remains in the database and backups, but the removed UI no longer displays it. - Historical boolean `enablePipelines` values are ignored. They cannot restore the feature. - Cases and Routines must retain their separate behavior. Targeted route tests cover this boundary. ## Model Used OpenAI Codex, GPT-6, with repository inspection, editing, shell execution, and browser verification. The exact deployed model ID and context-window size 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 `#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 - [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>
247 lines
12 KiB
TypeScript
247 lines
12 KiB
TypeScript
import { createAgentLifecycle } from "../services/agent-lifecycle.js";
|
|
import { randomUUID } from "node:crypto";
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
import { eq } from "drizzle-orm";
|
|
import { activityLog, agents, companies, companyMemberships, createDb, principalPermissionGrants } from "@paperclipai/db";
|
|
import { LOW_TRUST_REVIEW_PRESET, type PermissionKey } from "@paperclipai/shared";
|
|
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
|
import { agentService } from "../services/agents.js";
|
|
import { authorizationService } from "../services/authorization.js";
|
|
import { accessService } from "../services/access.js";
|
|
import { agentJoinGrantsFromDefaults } from "../services/invite-grants.js";
|
|
|
|
const support = await getEmbeddedPostgresTestSupport();
|
|
const describeDatabase = support.supported ? describe : describe.skip;
|
|
const expectedNewAgentGrantKeys: PermissionKey[] = [
|
|
"agents:configure",
|
|
"agents:suggest-changes",
|
|
"skills:create",
|
|
"skills:suggest-changes",
|
|
"tools:manage_connections",
|
|
"tools:manage_profiles",
|
|
"tools:view_audit",
|
|
"audit:view_agent_actions",
|
|
"tools:use",
|
|
"tools:manage_runtime",
|
|
"inbox:manage",
|
|
"tasks:assign",
|
|
"tasks:assign_scope",
|
|
"tasks:manage_active_checkouts",
|
|
].sort();
|
|
|
|
describeDatabase("new agent configuration defaults", () => {
|
|
let db!: ReturnType<typeof createDb>;
|
|
let cleanup: (() => Promise<void>) | undefined;
|
|
|
|
beforeAll(async () => {
|
|
const started = await startEmbeddedPostgresTestDatabase("agent-configure-defaults");
|
|
db = createDb(started.connectionString);
|
|
cleanup = started.cleanup;
|
|
}, 20_000);
|
|
|
|
afterAll(async () => {
|
|
await cleanup?.();
|
|
});
|
|
|
|
it("grants standard new agents direct peer configuration, but keeps restricted agents narrow", async () => {
|
|
const companyId = randomUUID();
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Agent permission defaults",
|
|
issuePrefix: `PD${companyId.slice(0, 6).toUpperCase()}`,
|
|
});
|
|
const create = (name: string, permissions: Record<string, unknown> = {}, metadata?: Record<string, unknown>) =>
|
|
createAgentLifecycle(db).requestHire(companyId, {
|
|
name,
|
|
role: "engineer",
|
|
adapterType: "process",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
permissions,
|
|
metadata,
|
|
}, metadata ? { allowBuiltInAgentMetadata: true } : undefined);
|
|
|
|
const standard = await create("Standard");
|
|
const peer = await create("Peer");
|
|
const lowTrust = await create("Low trust", { trustPreset: LOW_TRUST_REVIEW_PRESET });
|
|
const bundled = await create("Bundled", {}, {
|
|
paperclipBuiltInAgent: { key: "reflection-coach", featureKeys: [] },
|
|
});
|
|
|
|
const grants = await db.select().from(principalPermissionGrants)
|
|
.where(eq(principalPermissionGrants.companyId, companyId));
|
|
for (const agent of [standard, peer]) {
|
|
expect(grants.filter((grant) => grant.principalId === agent.id)
|
|
.map((grant) => grant.permissionKey).sort()).toEqual(expectedNewAgentGrantKeys);
|
|
expect(grants.find((grant) => grant.principalId === agent.id && grant.permissionKey === "tasks:assign_scope")?.scope)
|
|
.toEqual({ subtreeRootAgentId: agent.id });
|
|
expect(grants.find((grant) => grant.principalId === agent.id && grant.permissionKey === "inbox:manage")?.scope)
|
|
.toEqual({ responsibleUserOnly: true });
|
|
}
|
|
for (const agent of [lowTrust, bundled]) {
|
|
expect(grants.filter((grant) => grant.principalId === agent.id)).toEqual([]);
|
|
}
|
|
|
|
await db.insert(companyMemberships).values({
|
|
companyId,
|
|
principalType: "agent",
|
|
principalId: standard.id,
|
|
status: "active",
|
|
membershipRole: "member",
|
|
});
|
|
|
|
for (const permissionKey of expectedNewAgentGrantKeys) {
|
|
if (permissionKey === "tasks:assign_scope" || permissionKey === "inbox:manage") continue; // These grants require a target scope or the responsible-user inbox path.
|
|
expect(await accessService(db).hasPermission(companyId, "agent", standard.id, permissionKey)).toBe(true);
|
|
}
|
|
expect(await authorizationService(db).decidePrincipalGrant({
|
|
companyId,
|
|
principalType: "agent",
|
|
principalId: standard.id,
|
|
action: "tasks:assign",
|
|
permissionKey: "tasks:assign_scope",
|
|
scope: { assigneeAgentId: standard.id },
|
|
})).toMatchObject({ allowed: true });
|
|
expect(await authorizationService(db).decidePrincipalGrant({
|
|
companyId,
|
|
principalType: "agent",
|
|
principalId: standard.id,
|
|
action: "tasks:assign",
|
|
permissionKey: "tasks:assign_scope",
|
|
scope: { assigneeAgentId: peer.id },
|
|
})).toMatchObject({ allowed: false, reason: "deny_scope" });
|
|
for (const permissionKey of [
|
|
"agents:create", "environments:manage", "tools:admin", "users:invite",
|
|
"users:manage_permissions", "joins:approve",
|
|
] as const) {
|
|
expect(await accessService(db).hasPermission(companyId, "agent", standard.id, permissionKey)).toBe(false);
|
|
}
|
|
|
|
const decision = await authorizationService(db).decide({
|
|
actor: { type: "agent", agentId: standard.id, companyId, source: "agent_key" },
|
|
action: "agent_config:update",
|
|
resource: { type: "agent", agentId: peer.id, companyId },
|
|
scope: { requiresChangeGrant: true },
|
|
});
|
|
expect(decision).toMatchObject({ allowed: true, reason: "allow_direct_change" });
|
|
|
|
// A later ordinary self-update must not recreate a grant removed by an operator.
|
|
await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.principalId, standard.id));
|
|
await agentService(db).update(standard.id, { title: "Updated own title" });
|
|
expect(await db.select().from(principalPermissionGrants)
|
|
.where(eq(principalPermissionGrants.principalId, standard.id))).toEqual([]);
|
|
|
|
await db.update(agents).set({ status: "terminated", lifecycleState: "terminated" }).where(eq(agents.id, peer.id));
|
|
await agentService(db).remove(peer.id);
|
|
expect(await db.select().from(principalPermissionGrants)
|
|
.where(eq(principalPermissionGrants.principalId, peer.id))).toEqual([]);
|
|
|
|
await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.companyId, companyId));
|
|
await db.delete(companyMemberships).where(eq(companyMemberships.companyId, companyId));
|
|
await db.delete(agents).where(eq(agents.companyId, companyId));
|
|
await db.delete(activityLog).where(eq(activityLog.companyId, companyId));
|
|
await db.delete(companies).where(eq(companies.id, companyId));
|
|
});
|
|
|
|
it("keeps existing agents and their scoped grants unchanged during ordinary updates", async () => {
|
|
const companyId = randomUUID();
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Existing permission defaults",
|
|
issuePrefix: `PE${companyId.slice(0, 6).toUpperCase()}`,
|
|
});
|
|
const rows = await db.insert(agents).values([
|
|
{ companyId, name: "Existing", role: "engineer", permissions: {}, adapterType: "process", adapterConfig: {}, runtimeConfig: {} },
|
|
{ companyId, name: "Scoped", role: "engineer", permissions: {}, adapterType: "process", adapterConfig: {}, runtimeConfig: {} },
|
|
{ companyId, name: "Restricted", role: "engineer", permissions: { trustPreset: LOW_TRUST_REVIEW_PRESET }, adapterType: "process", adapterConfig: {}, runtimeConfig: {} },
|
|
{ companyId, name: "Built in", role: "engineer", metadata: { paperclipBuiltInAgent: { key: "reflection-coach", featureKeys: [] } }, permissions: {}, adapterType: "process", adapterConfig: {}, runtimeConfig: {} },
|
|
{ companyId, name: "Pending", role: "engineer", status: "pending_approval", permissions: {}, adapterType: "process", adapterConfig: {}, runtimeConfig: {} },
|
|
]).returning();
|
|
await db.insert(principalPermissionGrants).values({
|
|
companyId,
|
|
principalType: "agent",
|
|
principalId: rows[1]!.id,
|
|
permissionKey: "agents:configure",
|
|
scope: { agentIds: [rows[0]!.id] },
|
|
});
|
|
|
|
for (const row of rows) {
|
|
if (row.status === "pending_approval") {
|
|
await expect(agentService(db).update(row.id, { title: "Updated existing agent" }))
|
|
.rejects.toThrow("Pending approval agent configuration cannot be changed before board approval");
|
|
} else {
|
|
await agentService(db).update(row.id, { title: "Updated existing agent" });
|
|
}
|
|
}
|
|
const grants = await db.select().from(principalPermissionGrants)
|
|
.where(eq(principalPermissionGrants.companyId, companyId));
|
|
expect(grants.map((grant) => grant.principalId).sort()).toEqual([rows[1]!.id]);
|
|
expect(grants.map((grant) => grant.permissionKey)).toEqual(["agents:configure"]);
|
|
expect(grants.find((grant) => grant.principalId === rows[1]!.id)?.scope).toEqual({ agentIds: [rows[0]!.id] });
|
|
|
|
await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.companyId, companyId));
|
|
await db.delete(agents).where(eq(agents.companyId, companyId));
|
|
await db.delete(activityLog).where(eq(activityLog.companyId, companyId));
|
|
await db.delete(companies).where(eq(companies.id, companyId));
|
|
});
|
|
|
|
it("adds the default grant when a standard pending hire is approved", async () => {
|
|
const companyId = randomUUID();
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Pending permission default",
|
|
issuePrefix: `PP${companyId.slice(0, 6).toUpperCase()}`,
|
|
});
|
|
const pending = await createAgentLifecycle(db).requestHire(companyId, {
|
|
name: "Pending hire",
|
|
status: "pending_approval",
|
|
role: "engineer",
|
|
adapterType: "process",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
});
|
|
expect(await db.select().from(principalPermissionGrants)
|
|
.where(eq(principalPermissionGrants.companyId, companyId))).toEqual([]);
|
|
|
|
const result = await createAgentLifecycle(db).approveHire(pending.id);
|
|
expect(result?.activated).toBe(true);
|
|
expect((await db.select().from(principalPermissionGrants)
|
|
.where(eq(principalPermissionGrants.companyId, companyId)))
|
|
.map((grant) => grant.permissionKey).sort()).toEqual(expectedNewAgentGrantKeys);
|
|
|
|
await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.companyId, companyId));
|
|
await db.delete(agents).where(eq(agents.companyId, companyId));
|
|
await db.delete(activityLog).where(eq(activityLog.companyId, companyId));
|
|
await db.delete(companies).where(eq(companies.id, companyId));
|
|
});
|
|
|
|
it("keeps configuration access when invitation approval replaces a new agent's grants", async () => {
|
|
const companyId = randomUUID();
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Invited permission default",
|
|
issuePrefix: `PI${companyId.slice(0, 6).toUpperCase()}`,
|
|
});
|
|
const invited = await createAgentLifecycle(db).requestHire(companyId, {
|
|
name: "Invited agent",
|
|
role: "engineer",
|
|
adapterType: "process",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
});
|
|
await accessService(db).ensureMembership(companyId, "agent", invited.id, "member", "active");
|
|
await accessService(db).setPrincipalGrants(
|
|
companyId, "agent", invited.id, agentJoinGrantsFromDefaults(null, invited.id), null,
|
|
);
|
|
const grants = await db.select().from(principalPermissionGrants)
|
|
.where(eq(principalPermissionGrants.principalId, invited.id));
|
|
expect(grants.map((grant) => grant.permissionKey).sort()).toEqual(expectedNewAgentGrantKeys);
|
|
|
|
await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.companyId, companyId));
|
|
await db.delete(companyMemberships).where(eq(companyMemberships.companyId, companyId));
|
|
await db.delete(agents).where(eq(agents.companyId, companyId));
|
|
await db.delete(activityLog).where(eq(activityLog.companyId, companyId));
|
|
await db.delete(companies).where(eq(companies.id, companyId));
|
|
});
|
|
});
|