diff --git a/.agents/skills/stack/SKILL.md b/.agents/skills/stack/SKILL.md index 668e5ee46..761c095d5 100644 --- a/.agents/skills/stack/SKILL.md +++ b/.agents/skills/stack/SKILL.md @@ -79,6 +79,12 @@ Use this to understand local stack metadata, current branch position, missing parents, tracked PR numbers, and PR titles when GitHub is available. It is opinionated: backup branches are hidden, and when the current branch is stack-relevant it focuses on that stack instead of listing every local branch. +When relaying a stack view to the user, always include the full PR URLs for +every branch that has a PR, not just PR numbers or titles. If `stack status` +does not print a URL for a branch, query GitHub with `gh pr view`/`gh pr list` +and include the URL in the shown stack view. Use Markdown links or plain URL +lines for user-facing stack views so the PR links are clickable; do not hide the +only copy of the PR URLs inside a fenced code block. Use `stack sync --dry-run`, not `stack status`, when you need GitHub PR-base inference before mutation. @@ -129,6 +135,9 @@ nonzero if any stack failed. Sync output is intentionally outcome-oriented. It should show the stack tree with icons like `●`, `✓`, `◌`, and `✕`, plus changed PRs/backups/undo instructions. It should not default to internal phase logs like fetch, inspect, or reconcile. +When you show a stack tree to the user, include full PR URLs alongside each PR +entry so the stack view is directly actionable. Prefer Markdown list/tree output +with clickable links over fenced code blocks when relaying PR URLs to a user. If a replay fails, `stack sync` aborts the failed cherry-pick, restores the original branch, deletes the temporary replay branch, keeps backups and the undo diff --git a/.oxlintrc.jsonc b/.oxlintrc.jsonc index 261e4440d..8ad145a68 100644 --- a/.oxlintrc.jsonc +++ b/.oxlintrc.jsonc @@ -29,6 +29,7 @@ "executor/no-try-catch-or-throw": "error", "executor/no-unknown-error-message": "error", "executor/no-unsupported-effect-api": "error", + "executor/prefer-effect-predicate": "error", "executor/prefer-schema-inferred-types": "error", "executor/prefer-value-inferred-extension-types": "error", "executor/prefer-yield-tagged-error": "error", diff --git a/apps/cloud/src/auth/create-organization.e2e.node.test.ts b/apps/cloud/src/auth/create-organization.e2e.node.test.ts new file mode 100644 index 000000000..8685dd7c4 --- /dev/null +++ b/apps/cloud/src/auth/create-organization.e2e.node.test.ts @@ -0,0 +1,103 @@ +import { assert, describe, it } from "@effect/vitest"; +import { Effect, Exit } from "effect"; + +import { + CloudAuthApiTestContext, + CloudAuthApiTestContextLayer, + makeCloudAuthApiTestState, +} from "./cloud-auth-api.test-context"; +import { makeWorkOSTestMembership, makeWorkOSTestState } from "./workos.test-layer"; +import { makeAutumnTestState } from "../services/autumn.test-layer"; + +describe("create organization API", () => { + it.effect("lets a paid user create another organization through the HTTP API client", () => { + const state = makeCloudAuthApiTestState({ + workos: makeWorkOSTestState({ + memberships: [ + makeWorkOSTestMembership("org_free_1", "active"), + makeWorkOSTestMembership("org_free_2", "active"), + makeWorkOSTestMembership("org_paid", "active"), + ], + }), + autumn: makeAutumnTestState({ + subscriptionsByOrgId: { + org_paid: [{ planId: "team", status: "active" }], + }, + }), + }); + + return Effect.gen(function* () { + const { client } = yield* CloudAuthApiTestContext; + + const result = yield* client.cloudAuth.createOrganization({ + payload: { name: "Paid Extra Org" }, + }); + + assert.deepEqual(result, { id: "org_created", name: "Paid Extra Org" }); + assert.deepEqual(state.workos.createdOrganizations, [ + { id: "org_created", name: "Paid Extra Org" }, + ]); + assert.deepEqual(state.workos.createdMemberships, [ + { organizationId: "org_created", userId: "user_1", roleSlug: "admin" }, + ]); + assert.deepEqual(state.userStore.upsertedOrganizations, [ + { id: "org_created", name: "Paid Extra Org" }, + ]); + }).pipe(Effect.provide(CloudAuthApiTestContextLayer(state))); + }); + + it.effect( + "rejects a free-only user at the free organization limit through the HTTP API client", + () => { + const state = makeCloudAuthApiTestState({ + workos: makeWorkOSTestState({ + memberships: [ + makeWorkOSTestMembership("org_free_1", "active"), + makeWorkOSTestMembership("org_free_2", "active"), + makeWorkOSTestMembership("org_free_3", "active"), + ], + }), + }); + + return Effect.gen(function* () { + const { client } = yield* CloudAuthApiTestContext; + + const exit = yield* Effect.exit( + client.cloudAuth.createOrganization({ + payload: { name: "Blocked Org" }, + }), + ); + + assert.isTrue(Exit.isFailure(exit)); + assert.deepEqual(state.workos.createdOrganizations, []); + assert.deepEqual(state.workos.createdMemberships, []); + assert.deepEqual(state.userStore.upsertedOrganizations, []); + }).pipe(Effect.provide(CloudAuthApiTestContextLayer(state))); + }, + ); + + it.effect("does not count pending memberships toward the free organization limit", () => { + const state = makeCloudAuthApiTestState({ + workos: makeWorkOSTestState({ + memberships: [ + makeWorkOSTestMembership("org_free_1", "active"), + makeWorkOSTestMembership("org_free_2", "active"), + makeWorkOSTestMembership("org_invited", "pending"), + ], + }), + }); + + return Effect.gen(function* () { + const { client } = yield* CloudAuthApiTestContext; + + const result = yield* client.cloudAuth.createOrganization({ + payload: { name: "Below Active Limit" }, + }); + + assert.deepEqual(result, { id: "org_created", name: "Below Active Limit" }); + assert.deepEqual(state.workos.createdOrganizations, [ + { id: "org_created", name: "Below Active Limit" }, + ]); + }).pipe(Effect.provide(CloudAuthApiTestContextLayer(state))); + }); +}); diff --git a/apps/cloud/src/auth/handlers.ts b/apps/cloud/src/auth/handlers.ts index f1107d717..aac14c361 100644 --- a/apps/cloud/src/auth/handlers.ts +++ b/apps/cloud/src/auth/handlers.ts @@ -1,6 +1,6 @@ import { HttpApi, HttpApiBuilder } from "effect/unstable/httpapi"; import { HttpServerResponse } from "effect/unstable/http"; -import { Duration, Effect } from "effect"; +import { Duration, Effect, Predicate } from "effect"; import { setCookie, deleteCookie } from "@tanstack/react-start/server"; import { AUTH_PATHS, CloudAuthApi, CloudAuthPublicApi } from "./api"; @@ -12,6 +12,12 @@ import { ApiKeyManagementError } from "./api-key-errors"; import { WorkOSError } from "./errors"; import { WorkOSAuth } from "./workos"; import { ApiKeyService } from "./api-keys"; +import { AutumnService } from "../services/autumn"; +import { + hasPaidOrganizationSubscription, + isOverFreeOrganizationLimit, + shouldApplyFreeOrganizationLimit, +} from "./organization-limits"; const COOKIE_OPTIONS = { path: "/", @@ -49,7 +55,6 @@ const DELETE_COOKIE_OPTIONS = { secure: true, }; -const MAX_ORGANIZATIONS_PER_USER = 3; const MAX_API_KEY_NAME_LENGTH = 80; const randomState = (): string => { @@ -232,9 +237,7 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( ); return { - organizations: organizations.filter( - (org): org is NonNullable => org !== null, - ), + organizations: organizations.filter(Predicate.isNotNull), activeOrganizationId: session.organizationId, }; }), @@ -258,11 +261,38 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( const workos = yield* WorkOSAuth; const users = yield* UserStoreService; const session = yield* SessionContext; + const autumn = yield* AutumnService; const name = payload.name.trim(); const memberships = yield* workos.listUserMemberships(session.accountId); - if (memberships.data.length >= MAX_ORGANIZATIONS_PER_USER) { - return yield* new WorkOSError(); + const activeMemberships = memberships.data.filter( + (membership) => membership.status === "active", + ); + + if (isOverFreeOrganizationLimit(activeMemberships)) { + const paidOrganizationIds = yield* Effect.all( + activeMemberships.map((membership) => + autumn + .use((client) => + client.customers.getOrCreate({ customerId: membership.organizationId }), + ) + .pipe( + Effect.map((customer) => + hasPaidOrganizationSubscription(customer.subscriptions) + ? membership.organizationId + : null, + ), + ), + ), + { concurrency: 3 }, + ).pipe( + Effect.catchTag("AutumnError", () => Effect.fail(new WorkOSError())), + Effect.map((ids) => new Set(ids.filter(Predicate.isNotNull))), + ); + + if (shouldApplyFreeOrganizationLimit(activeMemberships, paidOrganizationIds)) { + return yield* new WorkOSError(); + } } const org = yield* workos.createOrganization(name); @@ -342,7 +372,7 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( ); return { - invitations: enriched.filter((i): i is NonNullable => i !== null), + invitations: enriched.filter(Predicate.isNotNull), }; }), ) diff --git a/apps/cloud/src/auth/organization-limits.node.test.ts b/apps/cloud/src/auth/organization-limits.node.test.ts new file mode 100644 index 000000000..ceec9da42 --- /dev/null +++ b/apps/cloud/src/auth/organization-limits.node.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { + FREE_ORGANIZATIONS_PER_USER_LIMIT, + hasPaidOrganizationSubscription, + isOverFreeOrganizationLimit, + shouldApplyFreeOrganizationLimit, +} from "./organization-limits"; + +describe("organization limits", () => { + it("treats active and trialing paid org subscriptions as paid", () => { + expect(hasPaidOrganizationSubscription([{ planId: "team", status: "active" }])).toBe(true); + expect(hasPaidOrganizationSubscription([{ planId: "team", status: "trialing" }])).toBe(true); + }); + + it("does not treat inactive paid plans or free plans as paid", () => { + expect(hasPaidOrganizationSubscription([{ planId: "team", status: "canceled" }])).toBe(false); + expect(hasPaidOrganizationSubscription([{ planId: "free", status: "active" }])).toBe(false); + expect(hasPaidOrganizationSubscription([{ planId: null, status: "active" }])).toBe(false); + }); + + it("applies the free org limit only when none of the user's active orgs are paid", () => { + const activeMemberships = [ + { organizationId: "org_free_1", status: "active" }, + { organizationId: "org_paid", status: "active" }, + ]; + + expect(shouldApplyFreeOrganizationLimit(activeMemberships, new Set())).toBe(true); + expect(shouldApplyFreeOrganizationLimit(activeMemberships, new Set(["org_paid"]))).toBe(false); + }); + + it("caps free-only users at active org memberships, not pending invitations", () => { + expect( + isOverFreeOrganizationLimit( + Array.from({ length: FREE_ORGANIZATIONS_PER_USER_LIMIT - 1 }, (_, index) => ({ + organizationId: `org_${index}`, + status: "active", + })), + ), + ).toBe(false); + + expect( + isOverFreeOrganizationLimit( + Array.from({ length: FREE_ORGANIZATIONS_PER_USER_LIMIT }, (_, index) => ({ + organizationId: `org_${index}`, + status: "active", + })), + ), + ).toBe(true); + }); +}); diff --git a/apps/cloud/src/auth/organization-limits.ts b/apps/cloud/src/auth/organization-limits.ts new file mode 100644 index 000000000..e67164273 --- /dev/null +++ b/apps/cloud/src/auth/organization-limits.ts @@ -0,0 +1,37 @@ +import { + ACTIVE_AUTUMN_SUBSCRIPTION_STATUSES, + PAID_AUTUMN_PLAN_IDS, +} from "../services/autumn-plans"; + +export const FREE_ORGANIZATIONS_PER_USER_LIMIT = 3; + +export type OrganizationLimitSubscriptionSummary = { + readonly planId?: string | null; + readonly status?: string | null; +}; + +export type OrganizationLimitMembershipSummary = { + readonly organizationId: string; + readonly status?: string | null; +}; + +export const isPaidOrganizationSubscription = ( + subscription: OrganizationLimitSubscriptionSummary, +): boolean => + subscription.planId != null && + PAID_AUTUMN_PLAN_IDS.has(subscription.planId) && + ACTIVE_AUTUMN_SUBSCRIPTION_STATUSES.has(subscription.status ?? ""); + +export const hasPaidOrganizationSubscription = ( + subscriptions: ReadonlyArray, +): boolean => subscriptions.some(isPaidOrganizationSubscription); + +export const shouldApplyFreeOrganizationLimit = ( + activeMemberships: ReadonlyArray, + paidOrganizationIds: ReadonlySet, +): boolean => + !activeMemberships.some((membership) => paidOrganizationIds.has(membership.organizationId)); + +export const isOverFreeOrganizationLimit = ( + activeMemberships: ReadonlyArray, +): boolean => activeMemberships.length >= FREE_ORGANIZATIONS_PER_USER_LIMIT; diff --git a/apps/cloud/src/org/member-limits.ts b/apps/cloud/src/org/member-limits.ts index e8734d43d..4d04ff39e 100644 --- a/apps/cloud/src/org/member-limits.ts +++ b/apps/cloud/src/org/member-limits.ts @@ -1,3 +1,5 @@ +import { ACTIVE_AUTUMN_SUBSCRIPTION_STATUSES } from "../services/autumn-plans"; + const MEMBER_LIMITS: Record = { free: 3, "free-pay-as-you-go": 3, @@ -16,7 +18,7 @@ export const selectActiveMemberLimitPlan = ( ): string => { const active = subscriptions.find((subscription) => - ["active", "trialing"].includes(subscription.status ?? ""), + ACTIVE_AUTUMN_SUBSCRIPTION_STATUSES.has(subscription.status ?? ""), ) ?? subscriptions[0]; return active?.planId ?? "free"; }; diff --git a/apps/cloud/src/services/autumn-plans.ts b/apps/cloud/src/services/autumn-plans.ts new file mode 100644 index 000000000..6250da651 --- /dev/null +++ b/apps/cloud/src/services/autumn-plans.ts @@ -0,0 +1,5 @@ +import { team } from "../../autumn.config"; + +export const PAID_AUTUMN_PLAN_IDS = new Set([team.id]); + +export const ACTIVE_AUTUMN_SUBSCRIPTION_STATUSES = new Set(["active", "trialing"]); diff --git a/apps/cloud/src/services/mcp-worker-transport.ts b/apps/cloud/src/services/mcp-worker-transport.ts index 5ddea1678..285e58f13 100644 --- a/apps/cloud/src/services/mcp-worker-transport.ts +++ b/apps/cloud/src/services/mcp-worker-transport.ts @@ -1,6 +1,6 @@ import { WorkerTransport, type WorkerTransportOptions } from "agents/mcp"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { Data, Effect, Exit, Match, Option } from "effect"; +import { Data, Effect, Exit, Match, Option, Predicate } from "effect"; export class McpWorkerTransportError extends Data.TaggedError("McpWorkerTransportError")<{ readonly cause: unknown; @@ -100,7 +100,7 @@ export class JsonRpcRequestIdQueue { const ids = [...new Set(await extractJsonRpcRequestIdKeys(request))]; if (ids.length === 0) return await run(); - const previous = ids.map((id) => this.inFlight.get(id)).filter((p) => p !== undefined); + const previous = ids.map((id) => this.inFlight.get(id)).filter(Predicate.isNotUndefined); let release!: () => void; const current = new Promise((resolve) => { release = resolve; diff --git a/packages/core/execution/src/tool-invoker.ts b/packages/core/execution/src/tool-invoker.ts index a4c492a64..d8f976e57 100644 --- a/packages/core/execution/src/tool-invoker.ts +++ b/packages/core/execution/src/tool-invoker.ts @@ -404,7 +404,7 @@ export const searchTools = Effect.fn("executor.tools.search")(function* ( const ranked = all .filter((tool: Tool) => matchesNamespace(tool, options?.namespace)) .map((tool: Tool) => scoreToolMatch(tool, query)) - .filter((tool): tool is ToolDiscoveryResult => tool !== null) + .filter(Predicate.isNotNull) .sort((left, right) => right.score - left.score || left.path.localeCompare(right.path)); const page = paginate(ranked, offset, limit); diff --git a/packages/core/sdk/src/oxlint-plugin-executor.test.ts b/packages/core/sdk/src/oxlint-plugin-executor.test.ts index dca1366cc..0bb7fdc64 100644 --- a/packages/core/sdk/src/oxlint-plugin-executor.test.ts +++ b/packages/core/sdk/src/oxlint-plugin-executor.test.ts @@ -139,4 +139,46 @@ describe("executor oxlint plugin", () => { expect(result.stdout).toContain("Found 0 warnings and 0 errors."); expect(result.stderr).toBe(""); }); + + it("rejects hand-rolled null predicates in Effect files", async () => { + const result = await runOxlintOn( + "manual-null-predicate.ts", + ` + import { Effect } from "effect"; + + const isNonNull = (value: A | null): value is A => value !== null; + + export const values = [Effect.void, null].filter(isNonNull); + `, + ); + + expect(result.status).toBe(1); + expect(result.stdout).toContain("executor(prefer-effect-predicate)"); + }); + + it("rejects inline nullish filter predicates in Effect files", async () => { + const result = await runOxlintOn( + "inline-nullish-filter.ts", + ` + import { Predicate } from "effect"; + + export const values = ["ok", null].filter((value): value is string => value !== null); + `, + ); + + expect(result.status).toBe(1); + expect(result.stdout).toContain("executor(prefer-effect-predicate)"); + }); + + it("allows null filters in files that do not import Effect", async () => { + const result = await runOxlintOn( + "plain-null-filter.ts", + ` + export const values = ["ok", null].filter((value): value is string => value !== null); + `, + ); + + expect(result.status).toBe(0); + expect(result.stdout).toContain("Found 0 warnings and 0 errors."); + }); }); diff --git a/packages/plugins/openapi/src/sdk/preview.ts b/packages/plugins/openapi/src/sdk/preview.ts index 59af4b0e7..34d251d86 100644 --- a/packages/plugins/openapi/src/sdk/preview.ts +++ b/packages/plugins/openapi/src/sdk/preview.ts @@ -1,4 +1,4 @@ -import { Effect, Option } from "effect"; +import { Effect, Option, Predicate } from "effect"; import { Schema } from "effect"; import { parse, resolveSpecText, type ParsedDocument } from "./parse"; @@ -255,7 +255,7 @@ const buildHeaderPresets = ( return strategies.flatMap((strategy) => { const resolved = strategy.schemes .map((name) => schemeMap.get(name)) - .filter((s): s is SecurityScheme => s !== undefined); + .filter(Predicate.isNotUndefined); if (resolved.length === 0) return []; diff --git a/scripts/oxlint-plugin-executor.js b/scripts/oxlint-plugin-executor.js index 60e4014cc..f390a8365 100644 --- a/scripts/oxlint-plugin-executor.js +++ b/scripts/oxlint-plugin-executor.js @@ -27,6 +27,7 @@ import noUnknownErrorMessage from "./oxlint-plugin-executor/rules/no-unknown-err import noUnknownShapeProbing from "./oxlint-plugin-executor/rules/no-unknown-shape-probing.js"; import noUnsupportedEffectApi from "./oxlint-plugin-executor/rules/no-unsupported-effect-api.js"; import noVitestImport from "./oxlint-plugin-executor/rules/no-vitest-import.js"; +import preferEffectPredicate from "./oxlint-plugin-executor/rules/prefer-effect-predicate.js"; import preferSchemaInferredTypes from "./oxlint-plugin-executor/rules/prefer-schema-inferred-types.js"; import preferYieldTaggedError from "./oxlint-plugin-executor/rules/prefer-yield-tagged-error.js"; import preferValueInferredExtensionTypes from "./oxlint-plugin-executor/rules/prefer-value-inferred-extension-types.js"; @@ -67,6 +68,7 @@ export default { "no-unknown-error-message": noUnknownErrorMessage, "no-unknown-shape-probing": noUnknownShapeProbing, "no-unsupported-effect-api": noUnsupportedEffectApi, + "prefer-effect-predicate": preferEffectPredicate, "prefer-schema-inferred-types": preferSchemaInferredTypes, "prefer-value-inferred-extension-types": preferValueInferredExtensionTypes, "prefer-yield-tagged-error": preferYieldTaggedError, diff --git a/scripts/oxlint-plugin-executor/rules/prefer-effect-predicate.js b/scripts/oxlint-plugin-executor/rules/prefer-effect-predicate.js new file mode 100644 index 000000000..11f76779d --- /dev/null +++ b/scripts/oxlint-plugin-executor/rules/prefer-effect-predicate.js @@ -0,0 +1,89 @@ +import { getPropertyName, isIdentifier, unwrapExpression } from "../utils.js"; + +const message = + "Use Predicate.isNotNull/isNotUndefined/isNotNullish from effect instead of hand-rolled nullish predicates."; + +const nullishOperators = new Set(["!==", "!=", "===", "=="]); + +const isNullLiteral = (node) => node?.type === "Literal" && node.value === null; +const isUndefinedIdentifier = (node) => isIdentifier(node, "undefined"); +const isNullishLiteral = (node) => isNullLiteral(node) || isUndefinedIdentifier(node); + +const isNullishComparison = (node, identifierName) => { + const expression = unwrapExpression(node); + if (expression?.type !== "BinaryExpression" || !nullishOperators.has(expression.operator)) { + return false; + } + + const left = unwrapExpression(expression.left); + const right = unwrapExpression(expression.right); + return ( + (isIdentifier(left, identifierName) && isNullishLiteral(right)) || + (isIdentifier(right, identifierName) && isNullishLiteral(left)) + ); +}; + +const getSingleIdentifierParamName = (params) => { + if (params.length !== 1) return undefined; + const param = unwrapExpression(params[0]); + return param?.type === "Identifier" ? param.name : undefined; +}; + +const isNullishPredicateFunction = (node) => { + const name = getSingleIdentifierParamName(node.params ?? []); + return name !== undefined && isNullishComparison(node.body, name); +}; + +const isFilterCall = (node) => { + const callee = unwrapExpression(node.callee); + return callee?.type === "MemberExpression" && getPropertyName(callee.property) === "filter"; +}; + +const importsEffect = (node) => { + const source = node.source; + return source?.type === "Literal" && source.value === "effect"; +}; + +export default { + meta: { + type: "problem", + docs: { + description: message, + }, + }, + create(context) { + let hasEffectImport = false; + + return { + ImportDeclaration(node) { + if (importsEffect(node)) { + hasEffectImport = true; + } + }, + VariableDeclarator(node) { + if (!hasEffectImport) return; + const init = unwrapExpression(node.init); + if (init?.type === "ArrowFunctionExpression" && isNullishPredicateFunction(init)) { + context.report({ node: init, message }); + } + }, + FunctionDeclaration(node) { + if (!hasEffectImport) return; + if (isNullishPredicateFunction(node)) { + context.report({ node, message }); + } + }, + CallExpression(node) { + if (!hasEffectImport || !isFilterCall(node)) return; + const predicate = unwrapExpression(node.arguments[0]); + if ( + (predicate?.type === "ArrowFunctionExpression" || + predicate?.type === "FunctionExpression") && + isNullishPredicateFunction(predicate) + ) { + context.report({ node: predicate, message }); + } + }, + }; + }, +};