mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 19:35:13 +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>
356 lines
16 KiB
TypeScript
356 lines
16 KiB
TypeScript
/**
|
|
* Cloud managed-config bootstrap (harness → app contract).
|
|
*
|
|
* Instances managed by the Paperclip Cloud harness receive one environment
|
|
* variable, `PAPERCLIP_MANAGED_CONFIG`, holding a single JSON document:
|
|
*
|
|
* {
|
|
* "v": 1,
|
|
* "mode": "cloud",
|
|
* "catalogVersion": "2026.720.0",
|
|
* "features": { "<feature-key>": true | false, ... },
|
|
* "plugins": { "autoInstall": ["daytona", "kubernetes"] },
|
|
* "environments": [
|
|
* { "name": "Daytona", "provider": "daytona", "config": { "target": "us" } }
|
|
* ]
|
|
* }
|
|
*
|
|
* Parsing follows the `execution-policy-bootstrap.ts` doctrine: a pure
|
|
* function over `Record<string, string | undefined>`, strict, and fail
|
|
* closed — bad JSON, an unsupported `v`, an unknown feature key, a feature
|
|
* key this build's feature catalog does not mark tier "managed", or any
|
|
* malformed section throws with a precise error so a managed instance
|
|
* refuses to start instead of silently dropping a security control. Only an
|
|
* ABSENT env var means self-hosted (no overlay, zero behavior change); a
|
|
* present-but-blank value or a document missing the `features` or
|
|
* `plugins.autoInstall` sections is malformed and fails startup, so a
|
|
* harness misconfiguration can never silently drop the managed overlay.
|
|
*
|
|
* Unlike the execution-policy bootstrap, the parsed document is NEVER
|
|
* persisted. `instanceSettingsService` overlays it at read time, so a DB
|
|
* restore or manual row edit cannot resurrect a capability the harness has
|
|
* disabled (see `applyManagedExperimentalOverlay` in instance-settings.ts).
|
|
*/
|
|
|
|
import {
|
|
INSTANCE_FEATURE_CATALOG,
|
|
instanceExperimentalSettingsSchema,
|
|
type ManagedExperimentalFeatureKey,
|
|
} from "@paperclipai/shared";
|
|
|
|
export type ManagedConfigEnv = Record<string, string | undefined>;
|
|
|
|
export const MANAGED_CONFIG_ENV_KEY = "PAPERCLIP_MANAGED_CONFIG";
|
|
export const SUPPORTED_MANAGED_CONFIG_VERSION = 1;
|
|
|
|
export interface ManagedInstanceConfig {
|
|
v: typeof SUPPORTED_MANAGED_CONFIG_VERSION;
|
|
mode: "cloud";
|
|
/** App feature-catalog version the document was validated against. */
|
|
catalogVersion: string;
|
|
features: Readonly<Partial<Record<ManagedExperimentalFeatureKey, boolean>>>;
|
|
plugins: { readonly autoInstall: readonly string[] };
|
|
/** Sandbox environments the control plane provisions at boot (empty when the section is absent). */
|
|
environments: readonly ManagedEnvironmentSpec[];
|
|
}
|
|
|
|
/**
|
|
* One declared managed sandbox environment. Provider-agnostic: `provider` is
|
|
* the sandbox plugin's driver key (== its `plugins.autoInstall` catalog key),
|
|
* and `config` is stored verbatim in the environment row for the plugin to
|
|
* validate at lease time — the same split `ensureKubernetesEnvironment` has
|
|
* always used, generalized to any bundled sandbox provider.
|
|
*/
|
|
export interface ManagedEnvironmentSpec {
|
|
/** Display name of the instance-level environment row (unique per instance). */
|
|
name: string;
|
|
description?: string;
|
|
/** Sandbox provider key (the plugin's driverKey, e.g. "daytona"). */
|
|
provider: string;
|
|
/** Provider config stored in `environment.config`; never carries secrets. */
|
|
config: Readonly<Record<string, unknown>>;
|
|
}
|
|
|
|
/**
|
|
* Managed-config documents deliver NO secrets, ever. Provider credentials
|
|
* reach a managed instance as process environment variables (every bundled
|
|
* sandbox provider falls back to its env var when `config` omits the key,
|
|
* e.g. `DAYTONA_API_KEY`), so any secret-looking config key in the document
|
|
* is a misrouted credential and fails startup. The environments API reuses
|
|
* this pattern to floor credential-shaped config keys out of
|
|
* platform-provisioned environment responses on managed instances.
|
|
*/
|
|
export const SECRET_LIKE_CONFIG_KEY_PATTERN = /(api[-_]?key|token|secret|password|credential)/i;
|
|
|
|
function findSecretLikeConfigKey(value: Record<string, unknown>, path: string): string | null {
|
|
for (const [key, child] of Object.entries(value)) {
|
|
const childPath = path.length > 0 ? `${path}.${key}` : key;
|
|
if (SECRET_LIKE_CONFIG_KEY_PATTERN.test(key)) return childPath;
|
|
if (isPlainObject(child)) {
|
|
const nested = findSecretLikeConfigKey(child, childPath);
|
|
if (nested) return nested;
|
|
} else if (Array.isArray(child)) {
|
|
for (const [index, element] of child.entries()) {
|
|
if (!isPlainObject(element)) continue;
|
|
const nested = findSecretLikeConfigKey(element, `${childPath}[${index}]`);
|
|
if (nested) return nested;
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
let cachedFeatureKeys: ReadonlySet<string> | null = null;
|
|
|
|
/**
|
|
* The set of feature keys a managed-config document may target: the boolean
|
|
* flag keys of `instanceExperimentalSettingsSchema`. The schema is the
|
|
* manifest — server-managed bookkeeping fields (activation cutoffs, lookback
|
|
* hours) are not booleans and are excluded by construction.
|
|
*/
|
|
export function managedFeatureKeySet(): ReadonlySet<string> {
|
|
if (!cachedFeatureKeys) {
|
|
const defaults = instanceExperimentalSettingsSchema.parse({}) as Record<string, unknown>;
|
|
cachedFeatureKeys = new Set(
|
|
Object.keys(defaults).filter((key) => typeof defaults[key] === "boolean"),
|
|
);
|
|
}
|
|
return cachedFeatureKeys;
|
|
}
|
|
|
|
function fail(detail: string): never {
|
|
throw new Error(`${MANAGED_CONFIG_ENV_KEY} ${detail}`);
|
|
}
|
|
|
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
function describeJsonValue(value: unknown): string {
|
|
if (value === null) return "null";
|
|
if (Array.isArray(value)) return "an array";
|
|
return `${JSON.stringify(value)}`;
|
|
}
|
|
|
|
/**
|
|
* Parse `PAPERCLIP_MANAGED_CONFIG` from a raw env map. Returns null only when
|
|
* the variable is absent (self-hosted). Throws with a precise error on a
|
|
* present-but-blank value or any malformed document so a cloud instance fails
|
|
* to start (fail closed).
|
|
*/
|
|
export function parseManagedConfigEnv(env: ManagedConfigEnv): ManagedInstanceConfig | null {
|
|
const raw = env[MANAGED_CONFIG_ENV_KEY];
|
|
if (raw === undefined) return null;
|
|
if (raw.trim().length === 0) {
|
|
fail(
|
|
"is set but blank; a managed instance requires the full JSON document (unset the variable entirely for self-hosted mode)",
|
|
);
|
|
}
|
|
|
|
let doc: unknown;
|
|
try {
|
|
doc = JSON.parse(raw);
|
|
} catch (err) {
|
|
fail(`is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
}
|
|
if (!isPlainObject(doc)) {
|
|
fail(`must be a JSON object (got ${describeJsonValue(doc)})`);
|
|
}
|
|
|
|
const allowedTopLevelKeys = new Set(["v", "mode", "catalogVersion", "features", "plugins", "environments"]);
|
|
for (const key of Object.keys(doc)) {
|
|
if (!allowedTopLevelKeys.has(key)) {
|
|
fail(`has unknown top-level key "${key}" (allowed: v, mode, catalogVersion, features, plugins, environments)`);
|
|
}
|
|
}
|
|
|
|
if (doc.v !== SUPPORTED_MANAGED_CONFIG_VERSION) {
|
|
fail(
|
|
`has unsupported "v" ${describeJsonValue(doc.v)}; this build supports v=${SUPPORTED_MANAGED_CONFIG_VERSION}`,
|
|
);
|
|
}
|
|
if (doc.mode !== "cloud") {
|
|
fail(`has invalid "mode" ${describeJsonValue(doc.mode)}; expected "cloud"`);
|
|
}
|
|
if (typeof doc.catalogVersion !== "string" || doc.catalogVersion.trim().length === 0) {
|
|
fail(`requires a non-empty string "catalogVersion" (got ${describeJsonValue(doc.catalogVersion)})`);
|
|
}
|
|
|
|
// The cloud contract is the FULL document: `features` and
|
|
// `plugins.autoInstall` are required (empty {} / [] are fine). A missing
|
|
// section means a truncated or mis-built document, and defaulting it to
|
|
// empty would silently drop the managed overlay or auto-install list.
|
|
// `plugins.autoInstall` is validated here as part of the atomic v1
|
|
// document; the bundled-plugin provisioning path that consumes it lands in
|
|
// the follow-up PR that generalizes `ensureBundledKubernetesPlugin`.
|
|
const features: Partial<Record<ManagedExperimentalFeatureKey, boolean>> = {};
|
|
if (doc.features === undefined) {
|
|
fail(`requires a "features" object mapping feature key → boolean (use {} for none)`);
|
|
}
|
|
if (!isPlainObject(doc.features)) {
|
|
fail(`"features" must be an object mapping feature key → boolean (got ${describeJsonValue(doc.features)})`);
|
|
}
|
|
const knownKeys = managedFeatureKeySet();
|
|
for (const [key, value] of Object.entries(doc.features)) {
|
|
if (key === "enablePipelines" && typeof value === "boolean") continue;
|
|
if (!knownKeys.has(key)) {
|
|
fail(
|
|
`"features" has unknown feature key "${key}"; known keys are the boolean flags of instanceExperimentalSettingsSchema`,
|
|
);
|
|
}
|
|
// Catalog-compatibility enforcement: the key exists in this build, but the
|
|
// control plane may only manage keys this build's feature catalog marks
|
|
// tier "managed". A key whose tier differs (a tenant `preference`, a
|
|
// code-pinned `floor`, or a tier demoted since the document's
|
|
// `catalogVersion` was published) is version skew — refuse startup rather
|
|
// than apply a control with mismatched catalog semantics.
|
|
const tier = INSTANCE_FEATURE_CATALOG[key as ManagedExperimentalFeatureKey].tier;
|
|
if (tier !== "managed") {
|
|
fail(
|
|
`"features" key "${key}" has tier "${tier}" in this build's feature catalog; only tier "managed" keys may be set by a managed-config document (catalogVersion ${JSON.stringify(doc.catalogVersion)} is incompatible with this build)`,
|
|
);
|
|
}
|
|
if (typeof value !== "boolean") {
|
|
fail(`"features.${key}" must be a boolean (got ${describeJsonValue(value)})`);
|
|
}
|
|
features[key as ManagedExperimentalFeatureKey] = value;
|
|
}
|
|
|
|
const autoInstall: string[] = [];
|
|
if (doc.plugins === undefined) {
|
|
fail(`requires a "plugins" object with an "autoInstall" array (use { "autoInstall": [] } for none)`);
|
|
}
|
|
if (!isPlainObject(doc.plugins)) {
|
|
fail(`"plugins" must be an object (got ${describeJsonValue(doc.plugins)})`);
|
|
}
|
|
for (const key of Object.keys(doc.plugins)) {
|
|
if (key !== "autoInstall") {
|
|
fail(`"plugins" has unknown key "${key}" (allowed: autoInstall)`);
|
|
}
|
|
}
|
|
const rawAutoInstall = doc.plugins.autoInstall;
|
|
if (rawAutoInstall === undefined) {
|
|
fail(`requires a "plugins.autoInstall" array of plugin keys (use [] for none)`);
|
|
}
|
|
if (!Array.isArray(rawAutoInstall)) {
|
|
fail(`"plugins.autoInstall" must be an array of plugin keys (got ${describeJsonValue(rawAutoInstall)})`);
|
|
}
|
|
for (const entry of rawAutoInstall) {
|
|
if (typeof entry !== "string" || entry.length === 0 || entry.trim() !== entry) {
|
|
fail(
|
|
`"plugins.autoInstall" entries must be non-empty strings without surrounding whitespace (got ${describeJsonValue(entry)})`,
|
|
);
|
|
}
|
|
if (autoInstall.includes(entry)) {
|
|
fail(`"plugins.autoInstall" has duplicate entry "${entry}"`);
|
|
}
|
|
autoInstall.push(entry);
|
|
}
|
|
|
|
// `environments` is OPTIONAL, unlike `features` and `plugins`: documents
|
|
// delivered before the section existed must keep booting newer builds (a
|
|
// fleet image roll cannot be lockstepped with a config re-delivery), and
|
|
// absence is not a dropped security control — it simply declares no managed
|
|
// environments. When present, the section is validated fail-closed like
|
|
// everything else.
|
|
const environmentSpecs: ManagedEnvironmentSpec[] = [];
|
|
if (doc.environments !== undefined) {
|
|
if (!Array.isArray(doc.environments)) {
|
|
fail(`"environments" must be an array of environment objects (got ${describeJsonValue(doc.environments)})`);
|
|
}
|
|
// The DB enforces at most ONE Paperclip-managed sandbox row per instance
|
|
// (partial unique index `environments_managed_sandbox_idx`); every entry
|
|
// here provisions that row, so a longer list can never be satisfied.
|
|
if (doc.environments.length > 1) {
|
|
fail(
|
|
`"environments" supports at most one entry: each entry provisions the single Paperclip-managed sandbox environment (DB invariant environments_managed_sandbox_idx)`,
|
|
);
|
|
}
|
|
for (const [index, entry] of doc.environments.entries()) {
|
|
if (!isPlainObject(entry)) {
|
|
fail(`"environments[${index}]" must be an object (got ${describeJsonValue(entry)})`);
|
|
}
|
|
const allowedEntryKeys = new Set(["name", "description", "provider", "config"]);
|
|
for (const key of Object.keys(entry)) {
|
|
if (!allowedEntryKeys.has(key)) {
|
|
fail(`"environments[${index}]" has unknown key "${key}" (allowed: name, description, provider, config)`);
|
|
}
|
|
}
|
|
if (typeof entry.name !== "string" || entry.name.length === 0 || entry.name.trim() !== entry.name) {
|
|
fail(
|
|
`"environments[${index}].name" must be a non-empty string without surrounding whitespace (got ${describeJsonValue(entry.name)})`,
|
|
);
|
|
}
|
|
if (
|
|
entry.description !== undefined
|
|
&& (typeof entry.description !== "string" || entry.description.trim().length === 0)
|
|
) {
|
|
fail(`"environments[${index}].description" must be a non-empty string when present (got ${describeJsonValue(entry.description)})`);
|
|
}
|
|
if (typeof entry.provider !== "string" || entry.provider.length === 0 || entry.provider.trim() !== entry.provider) {
|
|
fail(
|
|
`"environments[${index}].provider" must be a non-empty string without surrounding whitespace (got ${describeJsonValue(entry.provider)})`,
|
|
);
|
|
}
|
|
// Coherence: on a managed instance the control plane is the only plugin
|
|
// install path, so an environment whose provider plugin is not
|
|
// auto-installed could never serve a lease. Catch the skew at parse time.
|
|
if (!autoInstall.includes(entry.provider)) {
|
|
fail(
|
|
`"environments[${index}].provider" is "${entry.provider}", which is not in "plugins.autoInstall"; a managed environment requires its provider plugin to be provisioned`,
|
|
);
|
|
}
|
|
const config: Record<string, unknown> = {};
|
|
if (entry.config !== undefined) {
|
|
if (!isPlainObject(entry.config)) {
|
|
fail(`"environments[${index}].config" must be an object (got ${describeJsonValue(entry.config)})`);
|
|
}
|
|
if (entry.config.provider !== undefined) {
|
|
fail(`"environments[${index}].config" must not set "provider"; it is forced from the entry's provider key`);
|
|
}
|
|
const secretLikeKey = findSecretLikeConfigKey(entry.config, "");
|
|
if (secretLikeKey) {
|
|
fail(
|
|
`"environments[${index}].config" key "${secretLikeKey}" looks secret-bearing; credentials are delivered to managed instances as process environment variables (the provider's documented env fallback), never in the managed-config document`,
|
|
);
|
|
}
|
|
Object.assign(config, entry.config);
|
|
}
|
|
environmentSpecs.push(
|
|
Object.freeze({
|
|
name: entry.name,
|
|
...(entry.description !== undefined ? { description: entry.description } : {}),
|
|
provider: entry.provider,
|
|
config: Object.freeze(config),
|
|
}) as ManagedEnvironmentSpec,
|
|
);
|
|
}
|
|
}
|
|
|
|
return Object.freeze({
|
|
v: SUPPORTED_MANAGED_CONFIG_VERSION,
|
|
mode: "cloud",
|
|
catalogVersion: doc.catalogVersion,
|
|
features: Object.freeze(features),
|
|
plugins: Object.freeze({ autoInstall: Object.freeze(autoInstall) }),
|
|
environments: Object.freeze(environmentSpecs),
|
|
}) as ManagedInstanceConfig;
|
|
}
|
|
|
|
let cache: { raw: string | undefined; config: ManagedInstanceConfig | null } | null = null;
|
|
|
|
/**
|
|
* Parse-once accessor keyed on the raw env value. Callers that pass a custom
|
|
* env (tests) get a fresh parse whenever the raw value differs; process.env
|
|
* callers share one parsed document for the process lifetime.
|
|
*/
|
|
export function getManagedInstanceConfig(
|
|
env: ManagedConfigEnv = process.env,
|
|
): ManagedInstanceConfig | null {
|
|
const raw = env[MANAGED_CONFIG_ENV_KEY];
|
|
if (cache && cache.raw === raw) return cache.config;
|
|
const config = parseManagedConfigEnv(env);
|
|
cache = { raw, config };
|
|
return config;
|
|
}
|