From fd3a58e9a9b8f0e8e8a97379b146c7edb2e144db Mon Sep 17 00:00:00 2001 From: Lourince Daging Date: Tue, 14 Jul 2026 20:50:26 +0200 Subject: [PATCH] feat(engine): per-tenant configuration layer Adds a pure, deterministic per-tenant configuration layer for the Rent-a-Loop path (#4787, part of #4778): a customer's own autonomy level (mirroring #4782's graduated dial, taken as a value) and repo-specific execution preferences, scoped strictly to their rented repo. resolveTenantConfig merges a tenant's overrides onto the defaults, returning a config that shares no mutable reference with the defaults or any other resolution; setTenantConfig holds configs in an immutable store, returning a new store on every update. Isolation is guaranteed by construction, so one customer setting or mutating their config can never affect another tenant's config or the shared defaults. An unrecognized autonomy level falls back to the default rather than trusting arbitrary input. Resolves and holds config only; persistence is separate. Full coverage. Closes #4787 --- packages/loopover-engine/src/index.ts | 13 +++ packages/loopover-engine/src/tenant-config.ts | 83 +++++++++++++++++++ test/unit/tenant-config.test.ts | 70 ++++++++++++++++ 3 files changed, 166 insertions(+) create mode 100644 packages/loopover-engine/src/tenant-config.ts create mode 100644 test/unit/tenant-config.test.ts 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"); + }); +});