Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions packages/loopover-engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
83 changes: 83 additions & 0 deletions packages/loopover-engine/src/tenant-config.ts
Original file line number Diff line number Diff line change
@@ -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<TenantExecutionPreferences> | 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<Record<string, TenantConfig>>;

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();
}
70 changes: 70 additions & 0 deletions test/unit/tenant-config.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});