diff --git a/package.json b/package.json index a0844d17f8..fd98a50098 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "storybook": "pnpm --filter @paperclipai/ui storybook", "build-storybook": "pnpm --filter @paperclipai/ui build-storybook", "build": "pnpm run preflight:workspace-links && pnpm -r build", + "build:feature-catalog": "node cli/node_modules/tsx/dist/cli.mjs scripts/generate-feature-catalog.ts", "typecheck": "pnpm run preflight:workspace-links && pnpm -r typecheck", "typecheck:build-gaps": "pnpm run preflight:workspace-links && pnpm --filter @paperclipai/plugin-sdk ensure-build-deps && pnpm --filter @paperclipai/server build && node scripts/run-typecheck-build-gaps.mjs", "test": "pnpm run test:run", diff --git a/packages/shared/src/feature-catalog.test.ts b/packages/shared/src/feature-catalog.test.ts new file mode 100644 index 0000000000..5b63525b55 --- /dev/null +++ b/packages/shared/src/feature-catalog.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import { + FEATURE_TIERS, + INSTANCE_FEATURE_CATALOG, + INSTANCE_FEATURE_KEYS, + buildFeatureCatalogArtifact, + featureCatalogArtifactSchema, + renderFeatureCatalogArtifact, +} from "./feature-catalog.js"; +import { instanceExperimentalSettingsSchema } from "./validators/instance.js"; + +function schemaBooleanFlagKeys(): string[] { + return Object.entries(instanceExperimentalSettingsSchema.shape) + .filter(([, fieldSchema]) => { + let current: z.ZodTypeAny = fieldSchema as z.ZodTypeAny; + for (;;) { + if (current instanceof z.ZodDefault) { + current = current._def.innerType as z.ZodTypeAny; + } else if (current instanceof z.ZodOptional || current instanceof z.ZodNullable) { + current = current.unwrap() as z.ZodTypeAny; + } else { + break; + } + } + return current instanceof z.ZodBoolean; + }) + .map(([key]) => key) + .sort(); +} + +describe("INSTANCE_FEATURE_CATALOG", () => { + it("covers exactly the boolean flag keys of the experimental settings schema", () => { + expect([...INSTANCE_FEATURE_KEYS]).toEqual(schemaBooleanFlagKeys()); + }); + + it("keeps selfHostedDefault in sync with the schema defaults", () => { + const schemaDefaults = instanceExperimentalSettingsSchema.parse({}); + for (const key of INSTANCE_FEATURE_KEYS) { + expect(INSTANCE_FEATURE_CATALOG[key].selfHostedDefault, key).toBe(schemaDefaults[key]); + } + }); + + it("has a non-empty title, description, and valid tier for every flag", () => { + for (const key of INSTANCE_FEATURE_KEYS) { + const entry = INSTANCE_FEATURE_CATALOG[key]; + expect(entry.title.trim().length, key).toBeGreaterThan(0); + expect(entry.description.trim().length, key).toBeGreaterThan(0); + expect(FEATURE_TIERS, key).toContain(entry.tier); + } + }); +}); + +describe("buildFeatureCatalogArtifact", () => { + it("emits catalogVersion plus one tier entry per flag key", () => { + const artifact = buildFeatureCatalogArtifact("2026.720.0"); + expect(artifact.catalogVersion).toBe("2026.720.0"); + expect(Object.keys(artifact.features)).toEqual([...INSTANCE_FEATURE_KEYS]); + for (const key of INSTANCE_FEATURE_KEYS) { + expect(artifact.features[key]).toEqual({ tier: INSTANCE_FEATURE_CATALOG[key].tier }); + } + }); + + it("produces output that validates against featureCatalogArtifactSchema", () => { + const artifact = buildFeatureCatalogArtifact("2026.720.0"); + expect(featureCatalogArtifactSchema.parse(artifact)).toEqual(artifact); + }); + + it("rejects an empty catalogVersion", () => { + expect(() => buildFeatureCatalogArtifact("")).toThrow(/catalogVersion/); + expect(() => buildFeatureCatalogArtifact(" ")).toThrow(/catalogVersion/); + }); +}); + +describe("featureCatalogArtifactSchema", () => { + it("rejects unknown top-level or per-feature properties", () => { + const valid = buildFeatureCatalogArtifact("2026.720.0"); + expect( + featureCatalogArtifactSchema.safeParse({ ...valid, extra: true }).success, + ).toBe(false); + expect( + featureCatalogArtifactSchema.safeParse({ + catalogVersion: "2026.720.0", + features: { enableApps: { tier: "managed", extra: true } }, + }).success, + ).toBe(false); + }); + + it("rejects unknown tiers and a missing catalogVersion", () => { + expect( + featureCatalogArtifactSchema.safeParse({ + catalogVersion: "2026.720.0", + features: { enableApps: { tier: "mystery" } }, + }).success, + ).toBe(false); + expect( + featureCatalogArtifactSchema.safeParse({ features: {} }).success, + ).toBe(false); + }); +}); + +describe("renderFeatureCatalogArtifact", () => { + it("is deterministic, sorted, and newline-terminated", () => { + const rendered = renderFeatureCatalogArtifact("2026.720.0"); + expect(rendered).toBe(renderFeatureCatalogArtifact("2026.720.0")); + expect(rendered.endsWith("\n")).toBe(true); + const parsed = featureCatalogArtifactSchema.parse(JSON.parse(rendered)); + expect(Object.keys(parsed.features)).toEqual([...Object.keys(parsed.features)].sort()); + expect(parsed).toEqual(buildFeatureCatalogArtifact("2026.720.0")); + }); +}); diff --git a/packages/shared/src/feature-catalog.ts b/packages/shared/src/feature-catalog.ts new file mode 100644 index 0000000000..d2ca13b28b --- /dev/null +++ b/packages/shared/src/feature-catalog.ts @@ -0,0 +1,259 @@ +import { z } from "zod"; +import { instanceExperimentalSettingsSchema } from "./validators/instance.js"; + +/** + * Feature catalog for cloud-managed instances. + * + * The instance-settings zod schema is the feature manifest; this module adds + * only metadata about the flags the schema already declares. Keys are derived + * from the schema type, so adding, removing, or renaming a boolean flag in + * `instanceExperimentalSettingsSchema` without updating the metadata map is a + * compile error (and vice versa). + * + * Tiers: + * - `preference`: tenant-controllable taste setting; the cloud harness does + * not manage it. + * - `managed`: the cloud harness may set this per fleet/stack via + * `PAPERCLIP_MANAGED_CONFIG`. + * - `floor`: pinned by code on managed instances; no flag value may widen it. + */ +export const FEATURE_TIERS = ["preference", "managed", "floor"] as const; + +export type FeatureTier = (typeof FEATURE_TIERS)[number]; + +type ExperimentalSettings = z.infer; + +/** + * The boolean flag keys of the experimental settings schema. Non-flag keys + * (activation timestamps, numeric tuning values) are excluded. + */ +export type InstanceFeatureKey = { + [K in keyof ExperimentalSettings]: ExperimentalSettings[K] extends boolean ? K : never; +}[keyof ExperimentalSettings]; + +export interface FeatureCatalogEntry { + title: string; + description: string; + tier: FeatureTier; + /** Desired default on cloud-managed instances. */ + cloudDefault: boolean; + /** Must match the schema default; enforced by test. */ + selfHostedDefault: boolean; +} + +export const INSTANCE_FEATURE_CATALOG: Record = { + enableEnvironments: { + title: "Environments", + description: + "Show environment management in company settings and allow project and agent environment assignment controls.", + tier: "managed", + cloudDefault: false, + selfHostedDefault: false, + }, + enableIsolatedWorkspaces: { + title: "Isolated Workspaces", + description: + "Show execution workspace controls in project configuration and allow isolated workspace behavior for task runs.", + tier: "managed", + cloudDefault: false, + selfHostedDefault: false, + }, + enableStreamlinedLeftNavigation: { + title: "Streamlined Left Navigation", + description: "Use the streamlined main sidebar navigation layout.", + tier: "preference", + cloudDefault: true, + selfHostedDefault: true, + }, + enableApps: { + title: "Apps", + description: + "Show the Apps navigation and allow access to app connections, gateways, and advanced app tooling.", + tier: "managed", + cloudDefault: false, + selfHostedDefault: false, + }, + enablePipelines: { + title: "Pipelines", + description: "Enable pipeline definitions and pipeline-driven case production surfaces.", + tier: "managed", + cloudDefault: false, + selfHostedDefault: false, + }, + enableCases: { + title: "Cases", + description: + "Durable work products that tasks create and iterate on. Adds the Cases tab and the agent case API.", + tier: "managed", + cloudDefault: false, + selfHostedDefault: false, + }, + enableConferenceRoomChat: { + title: "Conference Room Chat", + description: + "Add the Conference Room team chat, the live activity feed, and the redesigned onboarding; restyles task threads as chat bubbles.", + tier: "managed", + cloudDefault: false, + selfHostedDefault: false, + }, + enableTaskWatchdogs: { + title: "Task Watchdogs", + description: + "Show task detail controls for configuring watchdog agents that verify stopped task subtrees and restore live paths when work should continue.", + tier: "managed", + cloudDefault: false, + selfHostedDefault: false, + }, + enableIssuePlanDecompositions: { + title: "Task Plan Decomposition Panel", + description: "Show accepted-plan decomposition history on task detail pages.", + tier: "managed", + cloudDefault: false, + selfHostedDefault: false, + }, + enableExperimentalFileViewer: { + title: "Experimental File Viewer", + description: + "Show task detail controls for browsing and previewing workspace files relative to a task.", + tier: "preference", + cloudDefault: false, + selfHostedDefault: false, + }, + enableCloudSync: { + title: "Cloud Sync", + description: + "Show local Paperclip Cloud upstream connection, preview, push, retry, and activation review surfaces.", + tier: "managed", + cloudDefault: false, + selfHostedDefault: false, + }, + enableExternalObjects: { + title: "External Objects", + description: + "Detect external URLs in issues and show resolved status for pull requests, tickets, and other referenced work objects.", + tier: "managed", + cloudDefault: false, + selfHostedDefault: false, + }, + enableSmokeLab: { + title: "Smoke Lab", + description: + "Add the Smoke Lab tab and dashboard card for exercising integration paths against deterministic local fixtures. Private deployments only.", + tier: "managed", + cloudDefault: false, + selfHostedDefault: false, + }, + enableBuiltInAgents: { + title: "Built-in Agents", + description: + "Show Paperclip-managed built-in agent surfaces, including roster badges, the Built-in agents tab, and setup controls.", + tier: "managed", + cloudDefault: false, + selfHostedDefault: false, + }, + enableSummaries: { + title: "Summaries", + description: + "Show Summarizer-generated status slots on project and workspace pages, with on-demand refresh and revision history.", + tier: "managed", + cloudDefault: false, + selfHostedDefault: false, + }, + enableDecisions: { + title: "Decisions", + description: + "Show the Decisions item in the main sidebar — the attention home that surfaces tasks awaiting input.", + tier: "preference", + cloudDefault: false, + selfHostedDefault: false, + }, + enableGoalsSidebarLink: { + title: "Goals Sidebar Link", + description: "Restore the Goals item in the main sidebar while the goals surface is being evaluated.", + tier: "preference", + cloudDefault: false, + selfHostedDefault: false, + }, + enableServerInfoDebugView: { + title: "Server Info Debug View", + description: + "Show a Server section in the account drawer with the current server restart time and running commit.", + tier: "preference", + cloudDefault: false, + selfHostedDefault: false, + }, + autoRestartDevServerWhenIdle: { + title: "Auto-Restart Dev Server When Idle", + description: + "In local development, wait for queued and running agent runs to finish, then restart the server automatically when backend changes make the current boot stale.", + tier: "preference", + cloudDefault: false, + selfHostedDefault: false, + }, + enableIssueGraphLivenessAutoRecovery: { + title: "Auto-Create Recovery Tasks", + description: + "Let the heartbeat scheduler create recovery tasks for task dependency chains found inside the configured lookback window.", + tier: "managed", + cloudDefault: false, + selfHostedDefault: false, + }, + enableWorkspaceBranchReconcileForward: { + title: "Workspace Branch Reconcile Forward", + description: + "Let execution workspaces reconcile a diverged recorded branch forward instead of failing branch containment.", + tier: "managed", + cloudDefault: true, + selfHostedDefault: true, + }, + enableWorkspaceDirtyQuarantineRepair: { + title: "Workspace Dirty Quarantine Repair", + description: + "Let workspace runtime recovery quarantine and repair dirty execution workspaces before runs.", + tier: "managed", + cloudDefault: true, + selfHostedDefault: true, + }, + enableWorktreeRunExecution: { + title: "Worktree Run Execution", + description: + "Let the scheduler execute runs inside an isolated git-worktree preview instance for tasks created after activation.", + tier: "managed", + cloudDefault: false, + selfHostedDefault: false, + }, +}; + +export const INSTANCE_FEATURE_KEYS = Object.keys(INSTANCE_FEATURE_CATALOG).sort() as InstanceFeatureKey[]; + +/** + * Shape of the `feature-catalog.json` release artifact the cloud harness + * imports per app release and validates feature writes against. + */ +export const featureCatalogArtifactSchema = z + .object({ + catalogVersion: z.string().min(1), + features: z.record( + z.string().min(1), + z.object({ tier: z.enum(FEATURE_TIERS) }).strict(), + ), + }) + .strict(); + +export type FeatureCatalogArtifact = z.infer; + +export function buildFeatureCatalogArtifact(catalogVersion: string): FeatureCatalogArtifact { + if (catalogVersion.trim().length === 0) { + throw new Error("catalogVersion must be a non-empty string"); + } + const features: FeatureCatalogArtifact["features"] = {}; + for (const key of INSTANCE_FEATURE_KEYS) { + features[key] = { tier: INSTANCE_FEATURE_CATALOG[key].tier }; + } + return { catalogVersion, features }; +} + +/** Deterministic serialization (sorted keys, trailing newline) for the artifact file. */ +export function renderFeatureCatalogArtifact(catalogVersion: string): string { + return `${JSON.stringify(buildFeatureCatalogArtifact(catalogVersion), null, 2)}\n`; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index e76366fe0d..e6f05efb3f 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -2219,3 +2219,15 @@ export { type EnvironmentCustomImageTerminalSessionToken, } from "./validators/environment-custom-images.js"; export * from "./validators/skill-policy.js"; +export { + FEATURE_TIERS, + INSTANCE_FEATURE_CATALOG, + INSTANCE_FEATURE_KEYS, + buildFeatureCatalogArtifact, + featureCatalogArtifactSchema, + renderFeatureCatalogArtifact, + type FeatureCatalogArtifact, + type FeatureCatalogEntry, + type FeatureTier, + type InstanceFeatureKey, +} from "./feature-catalog.js"; diff --git a/scripts/create-github-release.sh b/scripts/create-github-release.sh index 40a030cdb4..f326811a57 100755 --- a/scripts/create-github-release.sh +++ b/scripts/create-github-release.sh @@ -80,8 +80,30 @@ if ! git -C "$REPO_ROOT" rev-parse "$tag" >/dev/null 2>&1; then exit 1 fi +# The catalog is derived from the checked-out sources, so it must be generated +# from the exact commit the release tag points at, with no local edits. +tag_commit="$(git -C "$REPO_ROOT" rev-parse "$tag^{commit}")" +head_commit="$(git -C "$REPO_ROOT" rev-parse HEAD)" +if [ "$head_commit" != "$tag_commit" ]; then + echo "Error: HEAD ($head_commit) does not match tag $tag ($tag_commit). Check out the release tag before generating the feature catalog." >&2 + exit 1 +fi +if [ -n "$(git -C "$REPO_ROOT" status --porcelain --untracked-files=no)" ]; then + echo "Error: working tree has uncommitted changes. The feature catalog must be generated from the pristine release commit." >&2 + exit 1 +fi + +catalog_dir="$(mktemp -d)" +trap 'rm -rf "$catalog_dir"' EXIT +catalog_file="$catalog_dir/feature-catalog.json" +node "$REPO_ROOT/cli/node_modules/tsx/dist/cli.mjs" \ + "$REPO_ROOT/scripts/generate-feature-catalog.ts" \ + --version "$version" \ + --out "$catalog_file" + if [ "$dry_run" = true ]; then echo "[dry-run] gh release create $tag -R $GITHUB_REPO --title $tag --notes-file $notes_file" + echo "[dry-run] gh release upload $tag -R $GITHUB_REPO --clobber $catalog_file" exit 0 fi @@ -97,3 +119,6 @@ else gh release create "$tag" -R "$GITHUB_REPO" --title "$tag" --notes-file "$notes_file" echo "Created GitHub Release $tag" fi + +gh release upload "$tag" -R "$GITHUB_REPO" --clobber "$catalog_file" +echo "Uploaded feature-catalog.json to GitHub Release $tag" diff --git a/scripts/generate-feature-catalog.ts b/scripts/generate-feature-catalog.ts new file mode 100644 index 0000000000..7cce6ab017 --- /dev/null +++ b/scripts/generate-feature-catalog.ts @@ -0,0 +1,59 @@ +// Emits the feature-catalog.json release artifact the cloud harness imports +// per app release and validates feature writes against. The catalog content +// is derived from the instance-settings schema metadata in +// packages/shared/src/feature-catalog.ts. +// +// Usage: +// tsx scripts/generate-feature-catalog.ts --version 2026.720.0 [--out path/to/feature-catalog.json] +// +// Without --out, the artifact is written to stdout. + +import { mkdir, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import process from "node:process"; +import { renderFeatureCatalogArtifact } from "../packages/shared/src/feature-catalog.js"; + +function usage(): never { + console.error( + "Usage: tsx scripts/generate-feature-catalog.ts --version [--out ]", + ); + process.exit(1); +} + +let version = ""; +let outPath = ""; + +const args = process.argv.slice(2); +for (let i = 0; i < args.length; i += 1) { + switch (args[i]) { + case "--version": + version = args[++i] ?? ""; + break; + case "--out": + outPath = args[++i] ?? ""; + break; + case "-h": + case "--help": + usage(); + break; + default: + console.error(`Unknown argument: ${args[i]}`); + usage(); + } +} + +if (version.trim().length === 0) { + console.error("Error: --version is required and must be non-empty."); + usage(); +} + +const rendered = renderFeatureCatalogArtifact(version); + +if (outPath) { + const target = resolve(outPath); + await mkdir(dirname(target), { recursive: true }); + await writeFile(target, rendered, "utf8"); + console.error(`Wrote feature catalog for version ${version} to ${target}`); +} else { + process.stdout.write(rendered); +}