diff --git a/packages/loopover-engine/src/index.ts b/packages/loopover-engine/src/index.ts index ecfaa012c3..67356c778e 100644 --- a/packages/loopover-engine/src/index.ts +++ b/packages/loopover-engine/src/index.ts @@ -600,6 +600,19 @@ export { type TenantQuotaDecision, type TenantUsage, } from "./tenant-quota.js"; +export { + DEFAULT_TENANT_CONFIG, + EMPTY_TENANT_CONFIG_STORE, + getTenantConfig, + resolveTenantConfig, + setTenantConfig, + TENANT_AUTONOMY_LEVELS, + type TenantAutonomyLevel, + type TenantConfig, + type TenantConfigOverrides, + type TenantConfigStore, + type TenantExecutionPreferences, +} from "./tenant-config.js"; export { buildProgressSnapshot, progressChanged, diff --git a/packages/loopover-engine/src/tenant-config.ts b/packages/loopover-engine/src/tenant-config.ts new file mode 100644 index 0000000000..3bca22f873 --- /dev/null +++ b/packages/loopover-engine/src/tenant-config.ts @@ -0,0 +1,83 @@ +// Per-tenant configuration layer (pure) — #4787, part of the Rent-a-Loop path #4778. +// +// A customer's own autonomy/config, scoped strictly to their rented repo and independent of gittensory's own +// configuration. Deterministic and side-effect-free: it resolves a tenant's effective config from the defaults +// plus their overrides, and holds per-tenant configs in an IMMUTABLE store. Isolation is guaranteed by +// construction — every resolve returns a NEW config with freshly-copied collections, and every store update +// returns a NEW store, so setting or mutating one tenant's config can never affect another tenant's config or +// the shared defaults (the no-cross-contamination requirement). The autonomy level mirrors #4782's graduated +// dial (taken as a value here, not depending on its wiring). This resolves and holds config only — persisting +// it to a datastore is a separate, maintainer-owned concern. + +export type TenantAutonomyLevel = "off" | "suggest" | "assist" | "auto"; + +export const TENANT_AUTONOMY_LEVELS: readonly TenantAutonomyLevel[] = ["off", "suggest", "assist", "auto"]; + +/** Repo-specific execution preferences a tenant can tune for their own loop. */ +export type TenantExecutionPreferences = { + maxConcurrentLoops: number; + pauseOnFailure: boolean; + allowedActionClasses: readonly string[]; +}; + +export type TenantConfig = { + autonomyLevel: TenantAutonomyLevel; + preferences: TenantExecutionPreferences; +}; + +export type TenantConfigOverrides = { + autonomyLevel?: TenantAutonomyLevel | undefined; + preferences?: Partial | undefined; +}; + +/** The conservative baseline a tenant inherits until they override it. */ +export const DEFAULT_TENANT_CONFIG: TenantConfig = { + autonomyLevel: "suggest", + preferences: { maxConcurrentLoops: 1, pauseOnFailure: true, allowedActionClasses: ["open_pr", "comment"] }, +}; + +/** + * Resolve a tenant's effective config from the defaults plus their overrides. Pure and fully isolated: the + * returned config shares no mutable reference with the defaults or any other resolution — the action-class list + * is copied on every call — so mutating one tenant's config can never affect another's. An override with an + * unrecognized autonomy level falls back to the default level rather than trusting arbitrary input. + */ +export function resolveTenantConfig(overrides: TenantConfigOverrides = {}): TenantConfig { + const base = DEFAULT_TENANT_CONFIG; + const autonomyLevel = + overrides.autonomyLevel !== undefined && TENANT_AUTONOMY_LEVELS.includes(overrides.autonomyLevel) + ? overrides.autonomyLevel + : base.autonomyLevel; + const prefs = overrides.preferences ?? {}; + return { + autonomyLevel, + preferences: { + maxConcurrentLoops: prefs.maxConcurrentLoops ?? base.preferences.maxConcurrentLoops, + pauseOnFailure: prefs.pauseOnFailure ?? base.preferences.pauseOnFailure, + allowedActionClasses: [...(prefs.allowedActionClasses ?? base.preferences.allowedActionClasses)], + }, + }; +} + +/** An immutable map of tenant id → resolved config. Setting a tenant returns a new store (see below). */ +export type TenantConfigStore = Readonly>; + +export const EMPTY_TENANT_CONFIG_STORE: TenantConfigStore = Object.freeze({}); + +/** + * Set a tenant's config from their overrides, returning a NEW store. The updated tenant's entry is a freshly + * resolved config; every other tenant's entry is carried over untouched, so one customer setting their config + * can never mutate or observe another customer's. Immutable update — the input store is never modified. + */ +export function setTenantConfig( + store: TenantConfigStore, + tenantId: string, + overrides: TenantConfigOverrides = {}, +): TenantConfigStore { + return Object.freeze({ ...store, [tenantId]: resolveTenantConfig(overrides) }); +} + +/** Read a tenant's effective config, falling back to a fresh copy of the defaults when they've set none. */ +export function getTenantConfig(store: TenantConfigStore, tenantId: string): TenantConfig { + return store[tenantId] ?? resolveTenantConfig(); +} diff --git a/test/unit/tenant-config.test.ts b/test/unit/tenant-config.test.ts new file mode 100644 index 0000000000..d81eed161b --- /dev/null +++ b/test/unit/tenant-config.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; + +import { + DEFAULT_TENANT_CONFIG, + EMPTY_TENANT_CONFIG_STORE, + getTenantConfig, + resolveTenantConfig, + setTenantConfig, +} from "../../packages/loopover-engine/src/tenant-config"; + +describe("resolveTenantConfig (#4787)", () => { + it("returns the defaults when given no overrides", () => { + expect(resolveTenantConfig()).toEqual(DEFAULT_TENANT_CONFIG); + }); + + it("does not share a mutable reference with the defaults (fresh action-class list)", () => { + const cfg = resolveTenantConfig(); + (cfg.preferences.allowedActionClasses as string[]).push("merge"); + expect(DEFAULT_TENANT_CONFIG.preferences.allowedActionClasses).not.toContain("merge"); + }); + + it("applies a recognized autonomy-level override", () => { + expect(resolveTenantConfig({ autonomyLevel: "auto" }).autonomyLevel).toBe("auto"); + }); + + it("falls back to the default autonomy level when the override is unrecognized", () => { + expect(resolveTenantConfig({ autonomyLevel: "banana" as never }).autonomyLevel).toBe(DEFAULT_TENANT_CONFIG.autonomyLevel); + }); + + it("merges a partial preferences override onto the defaults", () => { + const cfg = resolveTenantConfig({ preferences: { maxConcurrentLoops: 5 } }); + expect(cfg.preferences.maxConcurrentLoops).toBe(5); + expect(cfg.preferences.pauseOnFailure).toBe(DEFAULT_TENANT_CONFIG.preferences.pauseOnFailure); + expect(cfg.preferences.allowedActionClasses).toEqual(DEFAULT_TENANT_CONFIG.preferences.allowedActionClasses); + }); + + it("honors an explicit false pauseOnFailure (not treated as absent) and a custom action-class list", () => { + const cfg = resolveTenantConfig({ preferences: { pauseOnFailure: false, allowedActionClasses: ["comment"] } }); + expect(cfg.preferences.pauseOnFailure).toBe(false); + expect(cfg.preferences.allowedActionClasses).toEqual(["comment"]); + }); +}); + +describe("tenant config store (#4787)", () => { + it("setTenantConfig returns a NEW store and never mutates the input (immutable update)", () => { + const s0 = EMPTY_TENANT_CONFIG_STORE; + const s1 = setTenantConfig(s0, "acme", { autonomyLevel: "auto" }); + expect(s1).not.toBe(s0); + expect(s0).toEqual({}); // input untouched + expect(getTenantConfig(s1, "acme").autonomyLevel).toBe("auto"); + }); + + it("getTenantConfig returns the defaults for a tenant that has set nothing", () => { + expect(getTenantConfig(EMPTY_TENANT_CONFIG_STORE, "unknown")).toEqual(DEFAULT_TENANT_CONFIG); + }); + + it("two tenants hold independent configs with no cross-contamination (acceptance)", () => { + let store = EMPTY_TENANT_CONFIG_STORE; + store = setTenantConfig(store, "tenant-a", { autonomyLevel: "auto", preferences: { allowedActionClasses: ["open_pr"] } }); + store = setTenantConfig(store, "tenant-b", { autonomyLevel: "off" }); + const a = getTenantConfig(store, "tenant-a"); + const b = getTenantConfig(store, "tenant-b"); + expect(a.autonomyLevel).toBe("auto"); + expect(b.autonomyLevel).toBe("off"); + // Mutating tenant A's resolved list must not affect tenant B or the defaults. + (a.preferences.allowedActionClasses as string[]).push("delete_repo"); + expect(getTenantConfig(store, "tenant-b").preferences.allowedActionClasses).not.toContain("delete_repo"); + expect(DEFAULT_TENANT_CONFIG.preferences.allowedActionClasses).not.toContain("delete_repo"); + }); +});