mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 10:13:11 +02: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>
373 lines
14 KiB
TypeScript
373 lines
14 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import {
|
|
MANAGED_CONFIG_ENV_KEY,
|
|
getManagedInstanceConfig,
|
|
managedFeatureKeySet,
|
|
parseManagedConfigEnv,
|
|
} from "../services/managed-config.js";
|
|
|
|
function envWith(raw: string | undefined) {
|
|
return { [MANAGED_CONFIG_ENV_KEY]: raw };
|
|
}
|
|
|
|
function validDoc(overrides: Record<string, unknown> = {}) {
|
|
return JSON.stringify({
|
|
v: 1,
|
|
mode: "cloud",
|
|
catalogVersion: "2026.720.0",
|
|
features: { enableApps: false, enableStatusCards: true },
|
|
plugins: { autoInstall: ["daytona", "kubernetes"] },
|
|
...overrides,
|
|
});
|
|
}
|
|
|
|
describe("managedFeatureKeySet", () => {
|
|
it("contains exactly the boolean flags of the experimental schema", () => {
|
|
const keys = managedFeatureKeySet();
|
|
expect(keys.has("enableApps")).toBe(true);
|
|
expect(keys.has("enableWorktreeRunExecution")).toBe(true);
|
|
// Server-managed bookkeeping fields are not overlayable features.
|
|
expect(keys.has("worktreeRunExecutionActivatedAt")).toBe(false);
|
|
expect(keys.has("worktreeRunExecutionActivationInstanceId")).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("parseManagedConfigEnv", () => {
|
|
it("returns null when the env var is absent (self-hosted)", () => {
|
|
expect(parseManagedConfigEnv({})).toBeNull();
|
|
expect(parseManagedConfigEnv(envWith(undefined))).toBeNull();
|
|
});
|
|
|
|
it("throws when the env var is present but blank (fail closed)", () => {
|
|
expect(() => parseManagedConfigEnv(envWith(""))).toThrow(/is set but blank/);
|
|
expect(() => parseManagedConfigEnv(envWith(" "))).toThrow(/is set but blank/);
|
|
expect(() => parseManagedConfigEnv(envWith("\n\t"))).toThrow(/is set but blank/);
|
|
});
|
|
|
|
it("parses a complete valid document", () => {
|
|
const config = parseManagedConfigEnv(envWith(validDoc()));
|
|
expect(config).toEqual({
|
|
v: 1,
|
|
mode: "cloud",
|
|
catalogVersion: "2026.720.0",
|
|
features: { enableApps: false, enableStatusCards: true },
|
|
plugins: { autoInstall: ["daytona", "kubernetes"] },
|
|
environments: [],
|
|
});
|
|
});
|
|
|
|
it("accepts the deprecated runner ingress key for managed-config compatibility", () => {
|
|
const config = parseManagedConfigEnv(
|
|
envWith(validDoc({ features: { enableRunnerPreviewIngress: true } })),
|
|
);
|
|
|
|
expect(config?.features).toEqual({ enableRunnerPreviewIngress: true });
|
|
});
|
|
|
|
it("accepts empty features {} and autoInstall [] sections", () => {
|
|
const config = parseManagedConfigEnv(
|
|
envWith(validDoc({ features: {}, plugins: { autoInstall: [] } })),
|
|
);
|
|
expect(config).toEqual({
|
|
v: 1,
|
|
mode: "cloud",
|
|
catalogVersion: "2026.720.0",
|
|
features: {},
|
|
plugins: { autoInstall: [] },
|
|
environments: [],
|
|
});
|
|
});
|
|
|
|
it("throws when the features section is missing (fail closed)", () => {
|
|
const doc = { v: 1, mode: "cloud", catalogVersion: "2026.720.0", plugins: { autoInstall: [] } };
|
|
expect(() => parseManagedConfigEnv(envWith(JSON.stringify(doc)))).toThrow(
|
|
/requires a "features" object/,
|
|
);
|
|
});
|
|
|
|
it("throws when the plugins section or autoInstall is missing (fail closed)", () => {
|
|
const noPlugins = {
|
|
v: 1,
|
|
mode: "cloud",
|
|
catalogVersion: "2026.720.0",
|
|
features: {},
|
|
};
|
|
expect(() => parseManagedConfigEnv(envWith(JSON.stringify(noPlugins)))).toThrow(
|
|
/requires a "plugins" object/,
|
|
);
|
|
expect(() => parseManagedConfigEnv(envWith(validDoc({ plugins: {} })))).toThrow(
|
|
/requires a "plugins.autoInstall" array/,
|
|
);
|
|
});
|
|
|
|
it("returns a frozen document", () => {
|
|
const config = parseManagedConfigEnv(envWith(validDoc()));
|
|
expect(Object.isFrozen(config)).toBe(true);
|
|
expect(Object.isFrozen(config?.features)).toBe(true);
|
|
expect(Object.isFrozen(config?.plugins.autoInstall)).toBe(true);
|
|
});
|
|
|
|
it("throws on invalid JSON", () => {
|
|
expect(() => parseManagedConfigEnv(envWith("{not json"))).toThrow(
|
|
/PAPERCLIP_MANAGED_CONFIG is not valid JSON/,
|
|
);
|
|
});
|
|
|
|
it("throws on non-object documents", () => {
|
|
expect(() => parseManagedConfigEnv(envWith("[]"))).toThrow(/must be a JSON object/);
|
|
expect(() => parseManagedConfigEnv(envWith("42"))).toThrow(/must be a JSON object/);
|
|
expect(() => parseManagedConfigEnv(envWith("null"))).toThrow(/must be a JSON object/);
|
|
expect(() => parseManagedConfigEnv(envWith('"cloud"'))).toThrow(/must be a JSON object/);
|
|
});
|
|
|
|
it("throws on an unknown top-level key", () => {
|
|
expect(() => parseManagedConfigEnv(envWith(validDoc({ extra: true })))).toThrow(
|
|
/unknown top-level key "extra"/,
|
|
);
|
|
});
|
|
|
|
it("throws on an unsupported v", () => {
|
|
expect(() => parseManagedConfigEnv(envWith(validDoc({ v: 2 })))).toThrow(
|
|
/unsupported "v" 2; this build supports v=1/,
|
|
);
|
|
expect(() => parseManagedConfigEnv(envWith(validDoc({ v: "1" })))).toThrow(/unsupported "v"/);
|
|
expect(() =>
|
|
parseManagedConfigEnv(
|
|
envWith(JSON.stringify({ mode: "cloud", catalogVersion: "x" })),
|
|
),
|
|
).toThrow(/unsupported "v"/);
|
|
});
|
|
|
|
it("throws on a non-cloud mode", () => {
|
|
expect(() => parseManagedConfigEnv(envWith(validDoc({ mode: "self-hosted" })))).toThrow(
|
|
/invalid "mode" "self-hosted"; expected "cloud"/,
|
|
);
|
|
expect(() =>
|
|
parseManagedConfigEnv(envWith(JSON.stringify({ v: 1, catalogVersion: "x" }))),
|
|
).toThrow(/invalid "mode"/);
|
|
});
|
|
|
|
it("throws on a missing or empty catalogVersion", () => {
|
|
expect(() =>
|
|
parseManagedConfigEnv(envWith(JSON.stringify({ v: 1, mode: "cloud" }))),
|
|
).toThrow(/non-empty string "catalogVersion"/);
|
|
expect(() => parseManagedConfigEnv(envWith(validDoc({ catalogVersion: "" })))).toThrow(
|
|
/non-empty string "catalogVersion"/,
|
|
);
|
|
expect(() => parseManagedConfigEnv(envWith(validDoc({ catalogVersion: 7 })))).toThrow(
|
|
/non-empty string "catalogVersion"/,
|
|
);
|
|
});
|
|
|
|
it("throws on a non-object features section", () => {
|
|
expect(() => parseManagedConfigEnv(envWith(validDoc({ features: ["enableApps"] })))).toThrow(
|
|
/"features" must be an object/,
|
|
);
|
|
});
|
|
|
|
it.each([true, false])("ignores the retired Pipelines flag (%s) from older Cloud catalogs", (enabled) => {
|
|
const config = parseManagedConfigEnv(envWith(validDoc({
|
|
features: { enablePipelines: enabled, enableCases: true },
|
|
})));
|
|
expect(config?.features).toEqual({ enableCases: true });
|
|
expect(managedFeatureKeySet().has("enablePipelines")).toBe(false);
|
|
});
|
|
|
|
it("throws on an unknown feature key", () => {
|
|
expect(() =>
|
|
parseManagedConfigEnv(envWith(validDoc({ features: { enableTimeTravel: true } }))),
|
|
).toThrow(/unknown feature key "enableTimeTravel"/);
|
|
// A server-managed bookkeeping field is not an overlayable feature.
|
|
expect(() =>
|
|
parseManagedConfigEnv(
|
|
envWith(validDoc({ features: { worktreeRunExecutionActivatedAt: true } })),
|
|
),
|
|
).toThrow(/unknown feature key "worktreeRunExecutionActivatedAt"/);
|
|
});
|
|
|
|
it("throws on a feature key the catalog does not mark tier \"managed\"", () => {
|
|
// `enableStreamlinedLeftNavigation` is a real schema flag, but its catalog
|
|
// tier is `preference` (tenant-controllable) — a managed-config document
|
|
// targeting it has incompatible catalog semantics and must fail closed.
|
|
expect(() =>
|
|
parseManagedConfigEnv(
|
|
envWith(validDoc({ features: { enableStreamlinedLeftNavigation: true } })),
|
|
),
|
|
).toThrow(
|
|
/"features" key "enableStreamlinedLeftNavigation" has tier "preference".*only tier "managed" keys/,
|
|
);
|
|
expect(() =>
|
|
parseManagedConfigEnv(envWith(validDoc({ features: { enableDecisions: false } }))),
|
|
).toThrow(/has tier "preference"/);
|
|
});
|
|
|
|
it("throws on non-boolean feature values", () => {
|
|
expect(() =>
|
|
parseManagedConfigEnv(envWith(validDoc({ features: { enableApps: "true" } }))),
|
|
).toThrow(/"features.enableApps" must be a boolean/);
|
|
expect(() =>
|
|
parseManagedConfigEnv(envWith(validDoc({ features: { enableApps: 1 } }))),
|
|
).toThrow(/"features.enableApps" must be a boolean/);
|
|
expect(() =>
|
|
parseManagedConfigEnv(envWith(validDoc({ features: { enableApps: null } }))),
|
|
).toThrow(/"features.enableApps" must be a boolean/);
|
|
});
|
|
|
|
it("throws on malformed plugins sections", () => {
|
|
expect(() => parseManagedConfigEnv(envWith(validDoc({ plugins: [] })))).toThrow(
|
|
/"plugins" must be an object/,
|
|
);
|
|
expect(() =>
|
|
parseManagedConfigEnv(envWith(validDoc({ plugins: { install: [] } }))),
|
|
).toThrow(/"plugins" has unknown key "install"/);
|
|
expect(() =>
|
|
parseManagedConfigEnv(envWith(validDoc({ plugins: { autoInstall: "daytona" } }))),
|
|
).toThrow(/"plugins.autoInstall" must be an array/);
|
|
expect(() =>
|
|
parseManagedConfigEnv(envWith(validDoc({ plugins: { autoInstall: [""] } }))),
|
|
).toThrow(/non-empty strings/);
|
|
expect(() =>
|
|
parseManagedConfigEnv(envWith(validDoc({ plugins: { autoInstall: [" daytona"] } }))),
|
|
).toThrow(/non-empty strings/);
|
|
expect(() =>
|
|
parseManagedConfigEnv(envWith(validDoc({ plugins: { autoInstall: [42] } }))),
|
|
).toThrow(/non-empty strings/);
|
|
expect(() =>
|
|
parseManagedConfigEnv(
|
|
envWith(validDoc({ plugins: { autoInstall: ["daytona", "daytona"] } })),
|
|
),
|
|
).toThrow(/duplicate entry "daytona"/);
|
|
});
|
|
});
|
|
|
|
describe("parseManagedConfigEnv environments section", () => {
|
|
const entry = (overrides: Record<string, unknown> = {}) => ({
|
|
name: "Daytona",
|
|
provider: "daytona",
|
|
config: { target: "us" },
|
|
...overrides,
|
|
});
|
|
|
|
it("defaults to an empty list when the section is absent (pre-section documents keep booting)", () => {
|
|
const config = parseManagedConfigEnv(envWith(validDoc()));
|
|
expect(config?.environments).toEqual([]);
|
|
});
|
|
|
|
it("parses a declared environment and freezes it", () => {
|
|
const config = parseManagedConfigEnv(
|
|
envWith(validDoc({ environments: [entry({ description: "Managed Daytona sandbox." })] })),
|
|
);
|
|
expect(config?.environments).toHaveLength(1);
|
|
const spec = config?.environments[0];
|
|
expect(spec?.name).toBe("Daytona");
|
|
expect(spec?.description).toBe("Managed Daytona sandbox.");
|
|
expect(spec?.provider).toBe("daytona");
|
|
expect(spec?.config).toEqual({ target: "us" });
|
|
expect(Object.isFrozen(config?.environments)).toBe(true);
|
|
expect(Object.isFrozen(spec)).toBe(true);
|
|
expect(Object.isFrozen(spec?.config)).toBe(true);
|
|
});
|
|
|
|
it("treats config and description as optional", () => {
|
|
const config = parseManagedConfigEnv(
|
|
envWith(validDoc({ environments: [{ name: "Daytona", provider: "daytona" }] })),
|
|
);
|
|
expect(config?.environments[0]?.config).toEqual({});
|
|
expect(config?.environments[0]?.description).toBeUndefined();
|
|
});
|
|
|
|
it("rejects a non-array section and non-object entries", () => {
|
|
expect(() =>
|
|
parseManagedConfigEnv(envWith(validDoc({ environments: {} }))),
|
|
).toThrow(/"environments" must be an array/);
|
|
expect(() =>
|
|
parseManagedConfigEnv(envWith(validDoc({ environments: ["daytona"] }))),
|
|
).toThrow(/"environments\[0\]" must be an object/);
|
|
});
|
|
|
|
it("rejects more than one entry (single managed sandbox slot)", () => {
|
|
expect(() =>
|
|
parseManagedConfigEnv(
|
|
envWith(validDoc({ environments: [entry(), entry({ name: "Other", provider: "kubernetes" })] })),
|
|
),
|
|
).toThrow(/at most one entry/);
|
|
});
|
|
|
|
it("rejects unknown entry keys", () => {
|
|
expect(() =>
|
|
parseManagedConfigEnv(envWith(validDoc({ environments: [entry({ envVars: {} })] }))),
|
|
).toThrow(/unknown key "envVars"/);
|
|
});
|
|
|
|
it("rejects malformed names, descriptions, and providers", () => {
|
|
expect(() =>
|
|
parseManagedConfigEnv(envWith(validDoc({ environments: [entry({ name: "" })] }))),
|
|
).toThrow(/"environments\[0\].name"/);
|
|
expect(() =>
|
|
parseManagedConfigEnv(envWith(validDoc({ environments: [entry({ name: " Daytona" })] }))),
|
|
).toThrow(/"environments\[0\].name"/);
|
|
expect(() =>
|
|
parseManagedConfigEnv(envWith(validDoc({ environments: [entry({ description: " " })] }))),
|
|
).toThrow(/"environments\[0\].description"/);
|
|
expect(() =>
|
|
parseManagedConfigEnv(envWith(validDoc({ environments: [entry({ provider: 7 })] }))),
|
|
).toThrow(/"environments\[0\].provider"/);
|
|
});
|
|
|
|
it("rejects a provider that plugins.autoInstall does not provision", () => {
|
|
expect(() =>
|
|
parseManagedConfigEnv(envWith(validDoc({ environments: [entry({ provider: "modal" })] }))),
|
|
).toThrow(/not in "plugins.autoInstall"/);
|
|
});
|
|
|
|
it("rejects a config that sets provider", () => {
|
|
expect(() =>
|
|
parseManagedConfigEnv(
|
|
envWith(validDoc({ environments: [entry({ config: { provider: "daytona" } })] })),
|
|
),
|
|
).toThrow(/must not set "provider"/);
|
|
});
|
|
|
|
it("rejects secret-bearing config keys at any depth (secrets travel as env vars)", () => {
|
|
expect(() =>
|
|
parseManagedConfigEnv(
|
|
envWith(validDoc({ environments: [entry({ config: { apiKey: "not-a-real-key" } })] })),
|
|
),
|
|
).toThrow(/looks secret-bearing/);
|
|
expect(() =>
|
|
parseManagedConfigEnv(
|
|
envWith(validDoc({ environments: [entry({ config: { auth: { accessToken: "t" } } })] })),
|
|
),
|
|
).toThrow(/auth.accessToken/);
|
|
expect(() =>
|
|
parseManagedConfigEnv(
|
|
envWith(
|
|
validDoc({ environments: [entry({ config: { adapters: [{ clientSecret: "s" }] } })] }),
|
|
),
|
|
),
|
|
).toThrow(/adapters\[0\].clientSecret/);
|
|
});
|
|
});
|
|
|
|
describe("getManagedInstanceConfig", () => {
|
|
it("caches by raw env value and reparses when it changes", () => {
|
|
const raw = validDoc();
|
|
const first = getManagedInstanceConfig(envWith(raw));
|
|
const second = getManagedInstanceConfig(envWith(raw));
|
|
expect(second).toBe(first);
|
|
|
|
const changed = getManagedInstanceConfig(
|
|
envWith(validDoc({ catalogVersion: "2026.721.0" })),
|
|
);
|
|
expect(changed?.catalogVersion).toBe("2026.721.0");
|
|
expect(changed).not.toBe(first);
|
|
|
|
expect(getManagedInstanceConfig(envWith(undefined))).toBeNull();
|
|
});
|
|
|
|
it("rethrows parse failures on every call instead of caching them", () => {
|
|
expect(() => getManagedInstanceConfig(envWith("{bad"))).toThrow(/not valid JSON/);
|
|
expect(() => getManagedInstanceConfig(envWith("{bad"))).toThrow(/not valid JSON/);
|
|
});
|
|
});
|