diff --git a/src/cli/lab.ts b/src/cli/lab.ts index 013b96327..59d1832e3 100644 --- a/src/cli/lab.ts +++ b/src/cli/lab.ts @@ -4,7 +4,8 @@ * Read commands use the local SQLite projection. Automation mutations and manual runs are * explicit operator actions; read commands never start probes or scheduler ticks. */ -import { getConfigDir, readConfigDiagnostics } from "../config"; +import { getConfigDir, readConfigDiagnostics, saveConfigPreservingClaudeCode } from "../config"; +import type { OcxConfig } from "../types"; import { ARTIFACT_CLASSES, EVIDENCE_LAYERS, @@ -63,6 +64,7 @@ import { planManualLabRun } from "../lab/automation/planner"; import { listLabAutomationRuns } from "../lab/automation/runs-query"; import { LabAutomationError, type LabAutomationLayer } from "../lab/automation/types"; import { createProductionLabRouteExecutor } from "../lib/lab-live-route-production"; +import { ensureLabAutomationRuntime } from "../server/lab-automation-runtime"; const USAGE = `Usage: ocx lab status [--json] @@ -87,6 +89,8 @@ type ArtifactStatus = (typeof ARTIFACT_STATUSES)[number]; export interface LabCliDeps { configDir?: string; + loadConfig?: () => OcxConfig; + saveConfig?: (config: OcxConfig) => void; } class LabStateError extends RuntimeApiError { @@ -104,6 +108,19 @@ function labErrorMessage(err: unknown): string { return "lab read failed"; } +function loadCliConfig(deps: LabCliDeps): OcxConfig { + return deps.loadConfig?.() ?? readConfigDiagnostics().config; +} + +function persistLabIntegrationOptIn(deps: LabCliDeps): void { + const config = loadCliConfig(deps); + if (config.labIntegrationEnabled === true) return; + (deps.saveConfig ?? saveConfigPreservingClaudeCode)({ + ...config, + labIntegrationEnabled: true, + }); +} + function takeEnumOption( args: string[], flag: string, @@ -394,8 +411,12 @@ export async function handleLabCommand(argv: string[], deps: LabCliDeps = {}): P }, taskEffectivenessBackgroundEnabled: false, }; + // `automation enable` is the explicit operator opt-in. Persist the + // core runtime gate too so server restart cannot silently disable it. + persistLabIntegrationOptIn(deps); saveLabAutomationPolicyConfig(policy, configDir); reconcileLabAutomationQueue(configDir); + ensureLabAutomationRuntime(loadCliConfig(deps), configDir); startLabAutomationScheduler(configDir); const status = buildLabAutomationStatus(configDir); printData(automationCliStatus(status), wantsJson, automationStatusLines(status)); @@ -435,7 +456,7 @@ export async function handleLabCommand(argv: string[], deps: LabCliDeps = {}): P const modelId = takeOption(rest, "--model"); rejectArgs(rest, USAGE); if (!layer || !scenarioId) throw new CliUsageError("--layer and --scenario are required", USAGE); - const configSnapshot = readConfigDiagnostics().config; + const configSnapshot = loadCliConfig(deps); if (layer === "live_route_compatibility") { const loadConfig = () => configSnapshot; setLabAutomationDispatchDeps({ diff --git a/src/config.ts b/src/config.ts index 703a444d8..85dcf4066 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1251,6 +1251,9 @@ const configSchema = z.object({ providerContextCaps: z.record(z.string(), z.number().int().positive()).optional(), contextCapValue: z.number().int().positive().optional(), multiAgentGuidanceEnabled: z.boolean().optional(), + // Compatibility Lab integration with the ordinary server runtime is explicit + // opt-in. A bad hand edit degrades to OFF instead of invalidating providers. + labIntegrationEnabled: z.boolean().optional().catch(false), // Invalid optional recovery config must not discard unrelated provider/account state. agentTaskRecovery: agentTaskRecoverySchema.optional().catch(undefined), // These selections pre-date schema validation and used to pass through as diff --git a/src/router.ts b/src/router.ts index 12edcd532..aa36e647c 100644 --- a/src/router.ts +++ b/src/router.ts @@ -31,7 +31,6 @@ import { } from "./routing/trace"; import { getRoutingProfile, resolvePolicyProfileId } from "./routing/profile"; import { evaluatePolicyProfile, type PolicyRequestEvidence } from "./routing/evaluator"; -import { assemblePolicyCandidateEvidence } from "./routing/compatibility/assemble"; export class NoEligiblePolicyCandidateError extends Error { /** Evaluation trace (with per-candidate exclusions) when nothing qualified. */ @@ -518,6 +517,11 @@ function routeModelInternal( const policyId = !bypassCombos ? resolvePolicyProfileId(config, modelId) : null; const profile = policyId ? getRoutingProfile(config, policyId) : undefined; if (profile && policyId) { + // Compatibility evidence is needed only for an explicit policy route. + // Keep its Lab-backed implementation out of the normal concrete route path. + const { assemblePolicyCandidateEvidence } = require( + "./routing/compatibility/assemble", + ) as typeof import("./routing/compatibility/assemble"); // One clock read per decision keeps candidate evidence, exclusions, and // scores mutually consistent and reproducible. const now = Date.now(); diff --git a/src/server/index.ts b/src/server/index.ts index 099991d27..def000db5 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -45,12 +45,6 @@ import { registerDefaultAppOwnedObservedBuffers, } from "../lib/app-owned-memory-stores"; import { acquireServerBackgroundLifecycle } from "./background-lifecycle"; -import { - setLabAutomationDispatchDeps, - startLabAutomationScheduler, -} from "../lab/automation/orchestrator"; -import { loadLabAutomationPolicy } from "../lab/automation/persistence"; -import { createProductionLabRouteExecutor } from "../lib/lab-live-route-production"; import { runOpenAiTierStartupMigration } from "../providers/openai-tier-startup"; import { runAlibabaRegionStartupMigration } from "../providers/alibaba-region-startup"; import { runModelRenameStartupMigration } from "../providers/model-rename-startup"; @@ -81,6 +75,7 @@ import { isDraining, registerTurn, runListenerShutdown, + setLabAutomationShutdownHook, setServerRef, trackStreamLifetime, tryAdmitTurn, @@ -1735,18 +1730,39 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server config, - }); - setLabAutomationDispatchDeps({ - configDir: labConfigDir, - loadConfig: () => config, - routeExecutor: productionLabRouteExecutor, - }); - if (loadLabAutomationPolicy(labConfigDir).enabled) { - startLabAutomationScheduler(labConfigDir); + // Compatibility Lab runtime integration is explicit opt-in. Keep all Lab + // automation modules outside the ordinary server startup graph while + // preserving startServer's synchronous API. + if (config.labIntegrationEnabled === true) { + const { + requestLabAutomationShutdown, + setLabAutomationDispatchDeps, + startLabAutomationScheduler, + stopLabAutomationScheduler, + } = require("../lab/automation/orchestrator") as typeof import("../lab/automation/orchestrator"); + const { loadLabAutomationPolicy } = require( + "../lab/automation/persistence", + ) as typeof import("../lab/automation/persistence"); + const { createProductionLabRouteExecutor } = require( + "../lib/lab-live-route-production", + ) as typeof import("../lib/lab-live-route-production"); + const labConfigDir = getConfigDir(); + const productionLabRouteExecutor = createProductionLabRouteExecutor({ + configDir: labConfigDir, + loadConfig: () => config, + }); + setLabAutomationDispatchDeps({ + configDir: labConfigDir, + loadConfig: () => config, + routeExecutor: productionLabRouteExecutor, + }); + setLabAutomationShutdownHook(() => { + requestLabAutomationShutdown(); + stopLabAutomationScheduler(); + }); + if (loadLabAutomationPolicy(labConfigDir).enabled) { + startLabAutomationScheduler(labConfigDir); + } } return server; diff --git a/src/server/lab-automation-runtime.ts b/src/server/lab-automation-runtime.ts new file mode 100644 index 000000000..bbb08b213 --- /dev/null +++ b/src/server/lab-automation-runtime.ts @@ -0,0 +1,68 @@ +import { readConfigDiagnostics } from "../config"; +import type { OcxConfig } from "../types"; +import { setLabAutomationShutdownHook } from "./lifecycle"; + +type RuntimeOwner = { + release: () => void; +}; + +const runtimeOwners = new Map(); + +function configKey(configDir?: string): string { + return configDir ?? ""; +} + +function loadLatestConfig(fallback: OcxConfig): OcxConfig { + try { + return readConfigDiagnostics().config; + } catch { + return fallback; + } +} + +function installShutdownHook(): void { + setLabAutomationShutdownHook(() => { + const { + requestLabAutomationShutdown, + stopLabAutomationScheduler, + } = require("../lab/automation/orchestrator") as typeof import("../lab/automation/orchestrator"); + requestLabAutomationShutdown(); + for (const [key, owner] of runtimeOwners) { + stopLabAutomationScheduler(key || undefined); + owner.release(); + } + runtimeOwners.clear(); + }); +} + +/** + * Lazily install the host-owned dispatch authority required by explicit Lab + * automation requests. Importing this module alone does not load Lab runtime + * code; the Lab graph is entered only when an explicit caller invokes this. + */ +export function ensureLabAutomationRuntime(config: OcxConfig, configDir?: string): void { + const key = configKey(configDir); + if (runtimeOwners.has(key)) return; + + const { setLabAutomationDispatchDeps } = require( + "../lab/automation/orchestrator", + ) as typeof import("../lab/automation/orchestrator"); + const { createProductionLabRouteExecutor } = require( + "../lib/lab-live-route-production", + ) as typeof import("../lib/lab-live-route-production"); + const loadConfig = () => loadLatestConfig(config); + const release = setLabAutomationDispatchDeps({ + configDir, + loadConfig, + routeExecutor: createProductionLabRouteExecutor({ configDir, loadConfig }), + }); + runtimeOwners.set(key, { release }); + installShutdownHook(); +} + +/** Test-only reset for direct management-route tests that do not own a server lifecycle. */ +export function resetLabAutomationRuntimeForTests(): void { + for (const owner of runtimeOwners.values()) owner.release(); + runtimeOwners.clear(); + setLabAutomationShutdownHook(null); +} diff --git a/src/server/lifecycle.ts b/src/server/lifecycle.ts index 9afb18e25..71922cf3d 100644 --- a/src/server/lifecycle.ts +++ b/src/server/lifecycle.ts @@ -7,7 +7,6 @@ import { } from "../storage/policy-job"; import { abortRestoreTrashJobAsync } from "../storage/restore-job"; import { stopStorageCleanupScheduler } from "../storage/policy-scheduler"; -import { stopLabAutomationScheduler, requestLabAutomationShutdown } from "../lab/automation/orchestrator"; import { stopStateStoreSweeper } from "../lib/state-store-sweeper"; import { cancelQueuedStorageWorkerSpawns, @@ -52,6 +51,17 @@ let _serverRef: ReturnType | undefined; let serverStopFlights = new WeakMap, Promise>(); let serverStartupReleaseFlights = new WeakMap, Promise>(); let releaseServerStartupLifecycleImpl: typeof releaseNativeMainStartupLifecycle = releaseNativeMainStartupLifecycle; +let labAutomationShutdownHook: (() => void) | null = null; + +export function setLabAutomationShutdownHook(hook: (() => void) | null): void { + labAutomationShutdownHook = hook; +} + +function runLabAutomationShutdownHook(): void { + const hook = labAutomationShutdownHook; + labAutomationShutdownHook = null; + hook?.(); +} export function setServerRef(server: ReturnType | undefined): void { _serverRef = server; } /** @@ -156,6 +166,7 @@ export function resetLifecycleDrainStateForTests(): void { serverStopFlights = new WeakMap, Promise>(); serverStartupReleaseFlights = new WeakMap, Promise>(); releaseServerStartupLifecycleImpl = releaseNativeMainStartupLifecycle; + labAutomationShutdownHook = null; } export function tryAdmitTurn(): ActiveTurnLease | null { if (isDraining()) return null; @@ -452,8 +463,7 @@ export async function drainAndShutdown( // Abort each job independently so one wedged join cannot skip the other, // then drain leftovers; failures must not prevent `server.stop`. stopStorageCleanupScheduler(); - requestLabAutomationShutdown(); - stopLabAutomationScheduler(); + runLabAutomationShutdownHook(); stopStateStoreSweeper(); // The overlay reconciler is owner-scoped: the startServer stop override // releases THIS server's lease through runListenerShutdown → diff --git a/src/server/management-api.ts b/src/server/management-api.ts index bff89ccea..342826c1b 100644 --- a/src/server/management-api.ts +++ b/src/server/management-api.ts @@ -61,15 +61,12 @@ import { handleConfigRoutes } from "./management/config-routes"; import { handleLogsUsageRoutes } from "./management/logs-usage-routes"; import { handleRequestHistoryRoutes } from "./management/request-history-routes"; import { handleRoutingAnalyticsRoutes } from "./management/routing-analytics-routes"; -import { handleRoutingProfileRoutes } from "./management/routing-profile-routes"; import { handleProviderRoutes } from "./management/provider-routes"; import { handleModelRoutes } from "./management/model-routes"; import { handleAgentSettingsRoutes } from "./management/agent-settings-routes"; import { handleOauthAccountRoutes } from "./management/oauth-account-routes"; import { handleComboRoutes } from "./management/combo-routes"; import { handleSystemRoutes } from "./management/system-routes"; -import { handleLabRoutes } from "./management/lab-routes"; -import { handleLabAutomationRoutes } from "./management/lab-automation-routes"; import { handleSidebarRoutes } from "./management/sidebar-routes"; import { handleIntegrationRoutes } from "./management/integration-routes"; import { handleNativeIntegrationRoutes } from "./management/native-integration-routes"; @@ -91,6 +88,26 @@ export const VERSION = (() => { } })(); +function pathInManagementNamespace(pathname: string, prefix: string): boolean { + return pathname === prefix || pathname.startsWith(`${prefix}/`); +} + +async function handleRoutingProfileRoutesOnDemand(ctx: ManagementContext): Promise { + if (!pathInManagementNamespace(ctx.url.pathname, "/api/routing-profiles")) return null; + const { handleRoutingProfileRoutes } = await import("./management/routing-profile-routes"); + return handleRoutingProfileRoutes(ctx); +} + +async function handleLabRoutesOnDemand(ctx: ManagementContext): Promise { + if (!pathInManagementNamespace(ctx.url.pathname, "/api/lab")) return null; + if (pathInManagementNamespace(ctx.url.pathname, "/api/lab/automation")) { + const { handleLabAutomationRoutes } = await import("./management/lab-automation-routes"); + return handleLabAutomationRoutes(ctx); + } + const { handleLabRoutes } = await import("./management/lab-routes"); + return handleLabRoutes(ctx); +} + const managementConvergenceBindings = new WeakMap) => ConvergeCodex; converge: ConvergeCodex; @@ -180,7 +197,7 @@ export async function handleManagementAPI( ?? (await handleLogsUsageRoutes(ctx)) ?? (await handleRequestHistoryRoutes(ctx)) ?? (await handleRoutingAnalyticsRoutes(ctx)) - ?? (await handleRoutingProfileRoutes(ctx)) + ?? (await handleRoutingProfileRoutesOnDemand(ctx)) ?? (await handleProviderRoutes(ctx)) ?? (await handleModelRoutes(ctx)) ?? (await handleIntegrationRoutes(ctx)) @@ -189,8 +206,7 @@ export async function handleManagementAPI( ?? (await handleOauthAccountRoutes(ctx)) ?? (await handleComboRoutes(ctx)) ?? (await handleSystemRoutes(ctx)) - ?? (await handleLabAutomationRoutes(ctx)) - ?? (await handleLabRoutes(ctx)) + ?? (await handleLabRoutesOnDemand(ctx)) ?? (await handleSidebarRoutes(ctx)); } catch (error) { const tooLarge = managementBodyTooLargeResponse(error, req, config); diff --git a/src/server/management/lab-automation-routes.ts b/src/server/management/lab-automation-routes.ts index dfaf0f4e9..1a1a5fa81 100644 --- a/src/server/management/lab-automation-routes.ts +++ b/src/server/management/lab-automation-routes.ts @@ -10,7 +10,7 @@ * Read endpoints never trigger scheduler ticks or evidence collection. */ -import { readConfigDiagnostics, getConfigDir } from "../../config"; +import { readConfigDiagnostics, getConfigDir, saveConfigPreservingClaudeCode } from "../../config"; import { buildLabAutomationStatus, cancelLabAutomationRun, @@ -33,6 +33,7 @@ import { listLabAutomationRuns } from "../../lab/automation/runs-query"; import type { LabAutomationLayer, LabAutomationPolicyV1 } from "../../lab/automation/types"; import { LabAutomationError } from "../../lab/automation/types"; import { jsonResponse } from "../auth-cors"; +import { ensureLabAutomationRuntime } from "../lab-automation-runtime"; import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body"; import type { ManagementContext } from "./context"; import { isPlainRecord } from "./shared"; @@ -63,7 +64,23 @@ function parseLimit(raw: string | null, ctx: ManagementContext): number | Respon return value; } -function applySchedulerPolicy(policy: LabAutomationPolicyV1, configDir?: string): void { +function persistLabIntegrationOptIn(ctx: ManagementContext): void { + if (ctx.config.labIntegrationEnabled === true) return; + const persistConfig = ctx.deps.saveConfigPreservingClaudeCode ?? saveConfigPreservingClaudeCode; + let current = ctx.config; + if (!ctx.deps.saveConfigPreservingClaudeCode) { + try { + current = readConfigDiagnostics().config; + } catch { + // The server already has a validated runtime config; use it as the safe fallback. + } + } + if (current.labIntegrationEnabled === true) return; + persistConfig({ ...current, labIntegrationEnabled: true }); +} + +function applySchedulerPolicy(policy: LabAutomationPolicyV1, config: import("../../types").OcxConfig, configDir?: string): void { + if (policy.enabled) ensureLabAutomationRuntime(config, configDir); reconcileLabAutomationQueue(configDir); if (policy.enabled) { startLabAutomationScheduler(configDir); @@ -137,6 +154,9 @@ export async function handleLabAutomationRoutes(ctx: ManagementContext): Promise } catch { ocxConfig = config; } + if (evidenceLayer === "live_route_compatibility") { + ensureLabAutomationRuntime(ocxConfig, configDir); + } const planned = planManualLabRun({ evidenceLayer: evidenceLayer as LabAutomationLayer, scenarioId, @@ -186,12 +206,16 @@ export async function handleLabAutomationRoutes(ctx: ManagementContext): Promise routes = normalizeLabAutomationRoutesV1(body.routes); } + // Runtime integration is a separate core-config gate. Enabling automation + // through this explicit operator surface must survive the next server restart. + if (policy.enabled) persistLabIntegrationOptIn(ctx); + // One atomic rename publishes policy and routes as a coherent generation. A failed write // leaves the previous generation authoritative and scheduler reconciliation is not applied. if (body.policy !== undefined || body.routes !== undefined) { saveLabAutomationConfig(policy, routes, configDir); } - applySchedulerPolicy(policy, configDir); + applySchedulerPolicy(policy, config, configDir); return jsonResponse(buildLabAutomationStatus(configDir), 200, req, config); } catch (error) { rethrowManagementBodyTooLarge(error); @@ -203,4 +227,4 @@ export async function handleLabAutomationRoutes(ctx: ManagementContext): Promise } return automationErrorResponse("not_found", "unknown resource", 404, ctx); -} \ No newline at end of file +} diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index ca928e21c..3e23e6104 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -33,7 +33,6 @@ import { type RouteResult, } from "../../router"; import { evidenceFromBody } from "../../routing/request-evidence"; -import { resolveProductionRouteSubject } from "../../routing/compatibility/subject"; import { advanceComboAfterFailure, comboDefaultEffort, @@ -1991,11 +1990,18 @@ async function handleResponsesInner( (logCtx.attempts ??= []).push(attempt); } sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, adapter.name, logCtx.accountLogLabel); - // CL-09: attach only the opaque exact route-subject identity to the attempt. - // This is best-effort passive metadata: no Lab state is created and failure - // must never alter, retry, or delay the upstream request. - if (logCtx.activeAttempt && !logCtx.activeAttempt.labRouteSubjectId) { + // CL-09 passive linkage is part of the explicit Lab integration only. Keep + // both the module load and subject construction out of the ordinary request + // path when Lab integration is disabled. + if ( + config.labIntegrationEnabled === true + && logCtx.activeAttempt + && !logCtx.activeAttempt.labRouteSubjectId + ) { try { + const { resolveProductionRouteSubject } = require( + "../../routing/compatibility/subject", + ) as typeof import("../../routing/compatibility/subject"); const passiveSubject = resolveProductionRouteSubject( config, route.providerName, diff --git a/src/types.ts b/src/types.ts index 6d0e84aff..a54bad32c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -925,6 +925,13 @@ export interface OcxConfig { }; /** Virtual `combo/` models spanning concrete provider/model targets (issue #133). */ combos?: Record; + /** + * Automatic Compatibility Lab integration with the ordinary server runtime. + * Default false. When disabled/unset, normal requests and server startup do + * not load Lab modules. Explicit Lab commands/APIs and explicit policy routes + * may still load their Lab dependencies on demand. + */ + labIntegrationEnabled?: boolean; /** * Routing policy profiles (Router Intelligence, RI-04+): explicitly requested * `policy/` (or configured alias) models select among an explicit diff --git a/tests/lab-core-isolation.test.ts b/tests/lab-core-isolation.test.ts new file mode 100644 index 000000000..bdc61c141 --- /dev/null +++ b/tests/lab-core-isolation.test.ts @@ -0,0 +1,56 @@ +import { spawnSync } from "node:child_process"; +import { describe, expect, test } from "bun:test"; + +const FORBIDDEN_RUNTIME_MODULES = [ + "/src/lab/", + "/src/lib/lab-live-route-production.ts", + "/src/routing/compatibility/assemble.ts", + "/src/server/management/lab-routes.ts", + "/src/server/management/lab-automation-routes.ts", + "/src/server/management/routing-profile-routes.ts", +] as const; + +function loadedModules(entry: string): string[] { + const script = ` + require(${JSON.stringify(`./${entry}`)}); + const loaded = Object.keys(require.cache).map((value) => value.replaceAll("\\\\", "/")); + process.stdout.write("__OCX_MODULES__" + JSON.stringify(loaded)); + `; + const result = spawnSync(process.execPath, ["-e", script], { + cwd: process.cwd(), + encoding: "utf8", + env: process.env, + }); + expect(result.status, result.stderr).toBe(0); + const marker = "__OCX_MODULES__"; + const markerIndex = result.stdout.lastIndexOf(marker); + expect(markerIndex).toBeGreaterThanOrEqual(0); + return JSON.parse(result.stdout.slice(markerIndex + marker.length)) as string[]; +} + +function expectCoreEntryIsolated(entry: string): void { + const loaded = loadedModules(entry); + const forbidden = loaded.filter((modulePath) => + FORBIDDEN_RUNTIME_MODULES.some((fragment) => modulePath.includes(fragment)) + ); + expect(forbidden).toEqual([]); +} + +describe("Compatibility Lab core isolation", () => { + test("ordinary Responses import graph does not load Lab runtime modules", () => { + expectCoreEntryIsolated("src/server/responses/core.ts"); + }); + + test("router import graph does not load compatibility assembly", () => { + expectCoreEntryIsolated("src/router.ts"); + }); + + test("server startup and lifecycle import graphs do not load Lab runtime modules", () => { + expectCoreEntryIsolated("src/server/index.ts"); + expectCoreEntryIsolated("src/server/lifecycle.ts"); + }); + + test("management API import graph keeps Lab and routing-profile handlers on demand", () => { + expectCoreEntryIsolated("src/server/management-api.ts"); + }); +}); diff --git a/tests/lab-runtime-integration-regressions.test.ts b/tests/lab-runtime-integration-regressions.test.ts new file mode 100644 index 000000000..f0208a28f --- /dev/null +++ b/tests/lab-runtime-integration-regressions.test.ts @@ -0,0 +1,162 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdirSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import type { OcxConfig } from "../src/types"; +import { readConfigDiagnostics, saveConfigPreservingClaudeCode } from "../src/config"; +import { handleLabCommand } from "../src/cli/lab"; +import { + enqueueManualLabRun, + requestLabAutomationShutdown, + resetLabAutomationSchedulerStateForTests, + stopLabAutomationScheduler, +} from "../src/lab/automation/orchestrator"; +import { loadLabAutomationConfig } from "../src/lab/automation/config-persistence"; +import { planManualLabRun } from "../src/lab/automation/planner"; +import { readInstallationSalt } from "../src/lab/subject/installation-salt"; +import { + resetCompatibilityVersionCacheForTests, + setCompatibilityVersionOverrideForTests, +} from "../src/routing/compatibility/version"; +import { handleManagementAPI } from "../src/server/management-api"; +import { resetLabAutomationRuntimeForTests } from "../src/server/lab-automation-runtime"; +import { ManagementRequest } from "./helpers/management-auth"; + +const HOMES: string[] = []; +const COMPAT_VERSION = "e".repeat(64); + +function tempHome(): string { + const dir = join(tmpdir(), `ocx-lab-runtime-${process.pid}-${Math.random().toString(16).slice(2)}`); + mkdirSync(dir, { recursive: true, mode: 0o700 }); + HOMES.push(dir); + process.env.OPENCODEX_HOME = dir; + return dir; +} + +function emptyConfig(): OcxConfig { + return { providers: {}, labIntegrationEnabled: false } as OcxConfig; +} + +function persistedLiveConfig(home: string): OcxConfig { + readInstallationSalt(home); + setCompatibilityVersionOverrideForTests(COMPAT_VERSION); + const base = readConfigDiagnostics().config; + const config = { + ...base, + defaultProvider: "fixture-provider", + labIntegrationEnabled: false, + providers: { + ...base.providers, + "fixture-provider": { + adapter: "openai-responses", + baseUrl: "https://example.com/v1", + models: ["fixture-model"], + defaultModel: "fixture-model", + }, + }, + } as OcxConfig; + saveConfigPreservingClaudeCode(config); + return config; +} + +afterEach(() => { + resetLabAutomationRuntimeForTests(); + requestLabAutomationShutdown(); + stopLabAutomationScheduler(); + resetLabAutomationSchedulerStateForTests(); + resetCompatibilityVersionCacheForTests(); + delete process.env.OPENCODEX_HOME; + for (const dir of HOMES.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +describe("Compatibility Lab runtime integration regressions", () => { + test("explicit live management run lazily installs production dispatch while integration is off", async () => { + const home = tempHome(); + const config = persistedLiveConfig(home); + const req = new ManagementRequest("http://127.0.0.1/api/lab/automation/run", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + evidenceLayer: "live_route_compatibility", + scenarioId: "responses-core.live.basic-turn", + providerName: "fixture-provider", + modelId: "fixture-model", + }), + }); + const res = await handleManagementAPI(req, new URL(req.url), config); + expect(res).not.toBeNull(); + expect(res!.status).toBe(200); + const body = await res!.json(); + expect(body.run.state).not.toBe("queued"); + expect(body.run.state).not.toBe("running"); + expect(body.run.terminalCode).not.toBe("route_ineligible"); + }); + + test("management automation enable persists the server runtime gate", async () => { + tempHome(); + const config = emptyConfig(); + let savedConfig: OcxConfig | undefined; + const req = new ManagementRequest("http://127.0.0.1/api/lab/automation", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + policy: { + enabled: true, + layers: { + protocolConformance: true, + liveRouteCompatibility: false, + taskEffectiveness: false, + }, + }, + }), + }); + const res = await handleManagementAPI(req, new URL(req.url), config, { + saveConfigPreservingClaudeCode: (next) => { + savedConfig = next; + }, + }); + expect(res).not.toBeNull(); + expect(res!.status).toBe(200); + expect(savedConfig?.labIntegrationEnabled).toBe(true); + expect(loadLabAutomationConfig().policy.enabled).toBe(true); + }); + + test("CLI automation enable persists the server runtime gate", async () => { + const home = tempHome(); + const config = emptyConfig(); + let savedConfig: OcxConfig | undefined; + expect(await handleLabCommand(["automation", "enable", "--protocol", "--json"], { + configDir: home, + loadConfig: () => config, + saveConfig: (next) => { + savedConfig = next; + }, + })).toBe(0); + expect(savedConfig?.labIntegrationEnabled).toBe(true); + expect(loadLabAutomationConfig(home).policy.enabled).toBe(true); + }); + + test("CLI live automation enable installs production dispatch dependencies", async () => { + const home = tempHome(); + persistedLiveConfig(home); + expect(await handleLabCommand(["automation", "enable", "--live", "--json"], { + configDir: home, + })).toBe(0); + + const config = readConfigDiagnostics().config; + expect(config.labIntegrationEnabled).toBe(true); + const planned = planManualLabRun({ + evidenceLayer: "live_route_compatibility", + scenarioId: "responses-core.live.basic-turn", + providerName: "fixture-provider", + modelId: "fixture-model", + config, + configDir: home, + }); + const record = await enqueueManualLabRun(planned, home); + expect(record).not.toBeNull(); + expect(record?.state).not.toBe("queued"); + expect(record?.state).not.toBe("running"); + expect(record?.terminalCode).not.toBe("route_ineligible"); + }); +});