// Docker E2E scheduler planning helpers. // This module turns the scenario catalog plus env-driven inputs into a concrete // lane plan. It intentionally does not define scenario commands. import { spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; import { existsSync, readFileSync } from "node:fs"; import { resolve } from "node:path"; import { resolveUpgradeSurvivorConfigStepsForBaseline } from "../e2e/lib/upgrade-survivor/config-recipe.mts"; import { BUNDLED_PLUGIN_INSTALL_UNINSTALL_SHARDS, allReleasePathLanes, fleetCacheLane, mainLanes, normalizeReleaseProfile, publicInstallerLanes, releasePathChunkLanes, tailLanes, type DockerE2eImageKind, type DockerE2eLane, type DockerE2eReleaseProfile, type DockerE2eReleaseProfileInput, } from "./docker-e2e-scenarios.mts"; import officialExternalChannelCatalog from "./official-external-channel-catalog.json" with { type: "json" }; import officialExternalProviderCatalog from "./official-external-provider-catalog.json" with { type: "json" }; import { isRecord } from "./record-shared.mjs"; import { UPDATE_FIRST_HOP_COMPAT_LANE, isUpdateFirstHopCompatLane, listRecordedFirstHopSourceVersions, updateFirstHopCompatLaneName, } from "./update-first-hop-lanes.mjs"; import { assertSupportedUpgradeSurvivorBaselineSpec, isTrustedHarnessOwnedUpgradeSurvivorScenario, parseUpgradeSurvivorBaselineSpecs, parseUpgradeSurvivorScenarios, supportsUpgradeSurvivorScenarioAtBaseline, } from "./upgrade-survivor-policy.mjs"; export { normalizeReleaseProfile }; export const DEFAULT_E2E_BARE_IMAGE = "openclaw-docker-e2e-bare:local"; export const DEFAULT_E2E_FUNCTIONAL_IMAGE = "openclaw-docker-e2e-functional:local"; export const DEFAULT_PARALLELISM = 10; export const DEFAULT_PROFILE = "all"; export const DEFAULT_RESOURCE_LIMITS = { docker: DEFAULT_PARALLELISM, live: 9, "live:claude": 4, "live:codex": 4, "live:droid": 4, "live:gemini": 4, "live:opencode": 4, "live:openai": 1, "live:telegram": 1, npm: 5, service: 7, }; export const DEFAULT_TAIL_PARALLELISM = 10; export const RELEASE_PATH_PROFILE = "release-path"; type LiveMode = "all" | "only" | "skip"; type DockerProfile = typeof DEFAULT_PROFILE | typeof RELEASE_PATH_PROFILE; type UpgradeSurvivorExpansion = { lanes: DockerE2eLane[]; omittedLaneNames: string[] }; type InertTargetContract = { mode: "inert"; source: { readText: (relativePath: string) => string | null }; }; // Inert postbuild declarations: shipped 9.1/9.2 literal outputs and the mapped // catalog that followed. Selection must not execute a frozen target's build code. const UPDATE_FIRST_HOP_COMPAT_CATALOGS = new Set([ "3a07518cac2a3f92c0ecb73e177ced4ae3350872be59c8c9a1871c2f0e3c0773", "edf5302a5bb101f2a2efaf9735cd0ae90081bd1b693e0c77a1f8e56ada865096", // Node-runner aliases for newer releases moved to the recorded package inventory. "0a12e16a5b6a2d723472cff04a05b356751da92a54c7b9a19cbb539c94190bb6", "2cb55271610aae5578175cf1587574231e9a72f9420a544d73370a8d3b8531ec", // Current outputs retire pre-June memory teardown stubs. "dfa812490cac8f09a274d698eb54c7c4a4b8474f3e264fbd38008af70b013cb3", ]); const IOS_WATCH_RELAY_COMMANDS = ['"watch.status"', '"watch.notify"']; type DockerE2ePlanOptions = { allowFrozenTargetScenarioOmissions?: boolean; frozenTarget?: InertTargetContract; includeOpenWebUI: boolean; liveMode: LiveMode; orderLanes: (lanes: DockerE2eLane[], timingStore?: unknown) => DockerE2eLane[]; planReleaseAll: boolean; profile: string; releaseChunk: string; releaseProfile?: DockerE2eReleaseProfileInput; selectedLaneNames: string[]; timingStore?: unknown; upgradeSurvivorBaselines?: string; upgradeSurvivorScenarios?: string; upgradeSurvivorTargetRoot?: string; }; export function parseLaneSelection(raw: string | undefined): string[] { if (!raw) { return []; } const laneAliases = new Map([ ["install-e2e", ["install-e2e-openai", "install-e2e-anthropic"]], [ UPDATE_FIRST_HOP_COMPAT_LANE, listRecordedFirstHopSourceVersions().map(updateFirstHopCompatLaneName), ], [ "bundled-plugin-install-uninstall", Array.from( { length: BUNDLED_PLUGIN_INSTALL_UNINSTALL_SHARDS }, (_, index) => `bundled-plugin-install-uninstall-${index}`, ), ], ]); return [ ...new Set( raw .split(/[,\s]+/u) .map((token) => token.trim()) .filter(Boolean) .flatMap((token) => laneAliases.get(token) ?? [token]), ), ]; } function shellQuote(value: string): string { return `'${value.replaceAll("'", "'\\''")}'`; } function sanitizeLaneNameSuffix(value: string): string { return ( value .replace(/^openclaw@/u, "") .replace(/[^A-Za-z0-9._-]+/g, "-") .replace(/^-+|-+$/g, "") || "baseline" ); } // Upgrade recipes select an OpenAI model whose runtime is supplied by the // version-matched Codex companion after the candidate replaces the baseline. const UPGRADE_SURVIVOR_RUNTIME_COMPANION_PACKAGES = ["@openclaw/codex"]; // Pre-protocol catalogs are content-addressed. Unknown legacy blocks fail // closed; current catalogs declare capabilities in JSON without executing target code. const LEGACY_UPGRADE_SURVIVOR_SCENARIO_CATALOGS = new Map([ [ "f2549a057028829ff5286db89d357e5b3d4ec1f5cdb3ca07672b7e34a739b60a", "base msteams-polls abandoned-update legacy-operator-state workshop-doctor-recovery mobile-pairing-reconnect acpx-openclaw-tools-bridge feishu-channel bootstrap-persona channel-post-core-restore codex-allowlist-survival plugin-deps-cleanup configured-plugin-installs missing-configured-plugin-migration custom-plugin-siblings projects-doctor taskflow-restoration stale-source-plugin-shadow prerelease-plugin-registry tilde-log-path meeting-transcripts-sqlite versioned-runtime-deps cron-scheduled-authority sqlite-volume recovery-cleanup auth-profile-v2026-7-2-beta-5 watchos-direct-node", ], [ "b0166f96bf3839d53ce94721b563fcf2b6604ddef04028cd8d9c7769275566c7", "base msteams-polls abandoned-update legacy-operator-state mobile-pairing-reconnect acpx-openclaw-tools-bridge feishu-channel bootstrap-persona channel-post-core-restore codex-allowlist-survival plugin-deps-cleanup configured-plugin-installs missing-configured-plugin-migration custom-plugin-siblings projects-doctor taskflow-restoration stale-source-plugin-shadow prerelease-plugin-registry tilde-log-path meeting-transcripts-sqlite versioned-runtime-deps cron-scheduled-authority sqlite-volume recovery-cleanup auth-profile-v2026-7-2-beta-5 watchos-direct-node", ], [ "7d9d7520c2c34d51fff78e542a7f539b77080bfd04648438e95aec4af3fe362e", "base msteams-polls abandoned-update legacy-operator-state workshop-doctor-recovery mobile-pairing-reconnect acpx-openclaw-tools-bridge feishu-channel bootstrap-persona channel-post-core-restore codex-allowlist-survival plugin-deps-cleanup configured-plugin-installs missing-configured-plugin-migration custom-plugin-siblings stale-source-plugin-shadow prerelease-plugin-registry tilde-log-path meeting-transcripts-sqlite versioned-runtime-deps cron-scheduled-authority sqlite-volume recovery-cleanup auth-profile-v2026-7-2-beta-5 watchos-direct-node", ], [ "5e8821538f3722fdf0dc3b3917ae639853f305e4c7a9b84febb8905601a9b5c7", "base msteams-polls abandoned-update legacy-operator-state mobile-pairing-reconnect acpx-openclaw-tools-bridge feishu-channel bootstrap-persona channel-post-core-restore codex-allowlist-survival plugin-deps-cleanup configured-plugin-installs missing-configured-plugin-migration custom-plugin-siblings stale-source-plugin-shadow prerelease-plugin-registry tilde-log-path meeting-transcripts-sqlite versioned-runtime-deps cron-scheduled-authority sqlite-volume recovery-cleanup auth-profile-v2026-7-2-beta-5 watchos-direct-node", ], [ "a4246e4fc65d037c173b38976f115faea015e476211f845bf328937805d81193", "base msteams-polls abandoned-update legacy-operator-state mobile-pairing-reconnect acpx-openclaw-tools-bridge feishu-channel bootstrap-persona channel-post-core-restore codex-allowlist-survival plugin-deps-cleanup configured-plugin-installs custom-plugin-siblings stale-source-plugin-shadow prerelease-plugin-registry tilde-log-path meeting-transcripts-sqlite versioned-runtime-deps cron-scheduled-authority sqlite-volume recovery-cleanup auth-profile-v2026-7-2-beta-5 watchos-direct-node", ], [ "733fc9c5b6895a9497b759534ad507cf67894d04c57f7b2439350d2000612d55", "base msteams-polls abandoned-update legacy-operator-state mobile-pairing-reconnect acpx-openclaw-tools-bridge feishu-channel bootstrap-persona channel-post-core-restore codex-allowlist-survival plugin-deps-cleanup configured-plugin-installs stale-source-plugin-shadow prerelease-plugin-registry tilde-log-path meeting-transcripts-sqlite versioned-runtime-deps cron-scheduled-authority sqlite-volume recovery-cleanup auth-profile-v2026-7-2-beta-5 watchos-direct-node", ], [ "6b80d370ff2cad1c122700264ddecdf392fb9957112a3b107dc5c9c9731b6646", "base abandoned-update legacy-operator-state mobile-pairing-reconnect acpx-openclaw-tools-bridge feishu-channel bootstrap-persona channel-post-core-restore codex-allowlist-survival plugin-deps-cleanup configured-plugin-installs stale-source-plugin-shadow prerelease-plugin-registry tilde-log-path meeting-transcripts-sqlite versioned-runtime-deps cron-scheduled-authority sqlite-volume recovery-cleanup auth-profile-v2026-7-2-beta-5 watchos-direct-node", ], [ "9c3b79d2fc1317a9b8033f59cb6ae350aebf8bd6ec9575d9704ed8d4b34b210d", "base legacy-operator-state mobile-pairing-reconnect acpx-openclaw-tools-bridge feishu-channel bootstrap-persona channel-post-core-restore codex-allowlist-survival plugin-deps-cleanup configured-plugin-installs stale-source-plugin-shadow prerelease-plugin-registry tilde-log-path meeting-transcripts-sqlite versioned-runtime-deps cron-scheduled-authority sqlite-volume recovery-cleanup auth-profile-v2026-7-2-beta-5 watchos-direct-node", ], [ "28758bbf9d4069d9718fb3325c59ad66a3bc6880248c104aa71b0d7769c54ba3", "base mobile-pairing-reconnect acpx-openclaw-tools-bridge feishu-channel bootstrap-persona channel-post-core-restore codex-allowlist-survival plugin-deps-cleanup configured-plugin-installs stale-source-plugin-shadow prerelease-plugin-registry tilde-log-path meeting-transcripts-sqlite versioned-runtime-deps cron-scheduled-authority sqlite-volume recovery-cleanup auth-profile-v2026-7-2-beta-5 watchos-direct-node", ], [ "dd12482a81dc5cc82dbb23f1cf3128321ec97a2176673c34adecbeed18d00484", "base acpx-openclaw-tools-bridge feishu-channel bootstrap-persona channel-post-core-restore codex-allowlist-survival plugin-deps-cleanup configured-plugin-installs stale-source-plugin-shadow prerelease-plugin-registry tilde-log-path meeting-transcripts-sqlite versioned-runtime-deps cron-scheduled-authority sqlite-volume recovery-cleanup auth-profile-v2026-7-2-beta-5 watchos-direct-node", ], [ "bb984a8abf8e4f5a5caaa0c6e10fc36efb6a05ca9f44fb960e4a6583cd52695d", "base acpx-openclaw-tools-bridge feishu-channel bootstrap-persona channel-post-core-restore codex-allowlist-survival plugin-deps-cleanup configured-plugin-installs stale-source-plugin-shadow prerelease-plugin-registry tilde-log-path meeting-transcripts-sqlite versioned-runtime-deps cron-scheduled-authority sqlite-volume recovery-cleanup auth-profile-v2026-7-2-beta-5", ], [ "f886fb3ca6232eb97cdfe93a9ab7fc8e8cd4a39658c5518bfb736a2242d0c949", "base acpx-openclaw-tools-bridge feishu-channel bootstrap-persona channel-post-core-restore codex-allowlist-survival plugin-deps-cleanup configured-plugin-installs stale-source-plugin-shadow prerelease-plugin-registry tilde-log-path meeting-transcripts-sqlite versioned-runtime-deps cron-scheduled-authority sqlite-volume auth-profile-v2026-7-2-beta-5", ], [ "0c5d3ce3533c035033890923aae7e210f4fdb24e7b8af32371930cdf12a00fd5", "base acpx-openclaw-tools-bridge feishu-channel bootstrap-persona channel-post-core-restore codex-allowlist-survival plugin-deps-cleanup configured-plugin-installs stale-source-plugin-shadow tilde-log-path meeting-transcripts-sqlite versioned-runtime-deps cron-scheduled-authority sqlite-volume auth-profile-v2026-7-2-beta-5", ], [ "837ab1c89821d52519f385e0f3d2067e0b923f730e3a4791e67f578bf5d29f8e", "base acpx-openclaw-tools-bridge feishu-channel bootstrap-persona channel-post-core-restore codex-allowlist-survival plugin-deps-cleanup configured-plugin-installs stale-source-plugin-shadow tilde-log-path meeting-transcripts-sqlite versioned-runtime-deps cron-scheduled-authority auth-profile-v2026-7-2-beta-5", ], [ "10ea475027d8b320d6a704cd6e4dd0f7e984c57a93b327048db229a7e0132c8a", "base acpx-openclaw-tools-bridge feishu-channel bootstrap-persona channel-post-core-restore codex-allowlist-survival plugin-deps-cleanup configured-plugin-installs stale-source-plugin-shadow tilde-log-path meeting-transcripts-sqlite versioned-runtime-deps cron-scheduled-authority", ], [ "213e004a28814fe0f7bb33018ae59a709c2e6d6e13b273df82a0e7935fbaf5af", "base acpx-openclaw-tools-bridge feishu-channel bootstrap-persona channel-post-core-restore codex-allowlist-survival plugin-deps-cleanup configured-plugin-installs stale-source-plugin-shadow tilde-log-path meeting-transcripts-sqlite versioned-runtime-deps", ], [ "755557a6ea609e5b9d9fe9d61beb7e75651641e26a3f0ef2fe1fc3a973b398c8", "base feishu-channel bootstrap-persona channel-post-core-restore plugin-deps-cleanup configured-plugin-installs stale-source-plugin-shadow tilde-log-path versioned-runtime-deps", ], [ "2657b5cc7b94cf46b9900f9259ee4cef0033837ee71a673e6ed09e7ee36684e8", "base feishu-channel bootstrap-persona tilde-log-path versioned-runtime-deps", ], [ "0fefb170b4131932c7403f7f0dcdd1371e300cdcc1c033b9bd2bd6513e08d5e4", "base feishu-channel bootstrap-persona plugin-deps-cleanup configured-plugin-installs stale-source-plugin-shadow tilde-log-path versioned-runtime-deps", ], [ "5a2c3a6e3d2a7166025333d4f1a77de0576847f9da7e902055160d0e5f20d4c5", "base feishu-channel bootstrap-persona plugin-deps-cleanup configured-plugin-installs tilde-log-path versioned-runtime-deps", ], [ "f226c05636dfb4e759558b5127d1c684d28a609292f4e110ff656c0e4f95ad06", "base acpx-openclaw-tools-bridge feishu-channel bootstrap-persona channel-post-core-restore plugin-deps-cleanup configured-plugin-installs stale-source-plugin-shadow tilde-log-path versioned-runtime-deps", ], [ "d9c9edcb27aca88a0b11c72e85592001cba732cf0f96bb8a73c1c0243c8f3678", "base acpx-openclaw-tools-bridge feishu-channel bootstrap-persona channel-post-core-restore codex-allowlist-survival plugin-deps-cleanup configured-plugin-installs stale-source-plugin-shadow tilde-log-path versioned-runtime-deps", ], [ "8ac0113158bfe1ebde77272fb1ffb740c281378a170fbc1e9e281d4e44677f02", "base feishu-channel bootstrap-persona plugin-deps-cleanup tilde-log-path versioned-runtime-deps", ], ]); function readLegacyFrozenScenarioContract(source: string): string[] | undefined { const startMarker = "const SCENARIOS = new Set(["; const start = source.indexOf(startMarker); if (start < 0 || source.lastIndexOf(startMarker) !== start) { return undefined; } const end = source.indexOf("\n]);", start + startMarker.length); if (end < 0) { return undefined; } const block = source.slice(start, end + 4); const digest = createHash("sha256").update(block).digest("hex"); return LEGACY_UPGRADE_SURVIVOR_SCENARIO_CATALOGS.get(digest)?.split(" "); } function readInertFrozenScenarioContract( source: string, targetRoot: string | undefined, frozenTarget?: InertTargetContract, ): string[] | undefined { const text = readTargetMetadata( targetRoot, "scripts/lib/upgrade-survivor-scenarios.json", frozenTarget, ); if (text === null) { return readLegacyFrozenScenarioContract(source); } // Read declared capabilities as data; never evaluate the selected tree's modules. let catalog: unknown; try { catalog = JSON.parse(text); } catch { return undefined; } if ( !catalog || typeof catalog !== "object" || Array.isArray(catalog) || Object.keys(catalog).length !== 2 || !("scenarios" in catalog) || !("assertionOnlyScenarios" in catalog) || !Array.isArray(catalog.scenarios) || catalog.scenarios.length === 0 || !Array.isArray(catalog.assertionOnlyScenarios) ) { return undefined; } const scenarios: unknown[] = [...catalog.scenarios, ...catalog.assertionOnlyScenarios]; if ( !scenarios.every( (scenario): scenario is string => typeof scenario === "string" && /^[a-z0-9][a-z0-9-]*$/u.test(scenario), ) || new Set(scenarios).size !== scenarios.length ) { return undefined; } return scenarios; } function readFrozenScenarioContract( assertionsFile: string, targetRoot: string, allowExecutableContract: boolean, ): string[] { if (!allowExecutableContract) { const inertScenarios = readInertFrozenScenarioContract( readFileSync(assertionsFile, "utf8"), targetRoot, ); if (inertScenarios) { return inertScenarios; } throw new Error( `cannot read frozen upgrade-survivor scenarios from ${assertionsFile}: unrecognized scenario contract; require trusted workflow opt-in before executing target code`, ); } // Canonical frozen refs may expose a dependency-free catalog command. Run it // only after the release workflow explicitly establishes the trust boundary. const nodeExecPath = process.versions.bun ? "node" : process.execPath; const result = spawnSync(nodeExecPath, [assertionsFile, "list-scenarios"], { cwd: targetRoot, encoding: "utf8", }); if (result.status !== 0) { const errorLines = result.stderr.trim().split("\n"); if (result.stderr.includes("unknown upgrade-survivor assertion command: list-scenarios")) { const legacyScenarios = readInertFrozenScenarioContract( readFileSync(assertionsFile, "utf8"), targetRoot, ); if (legacyScenarios) { return legacyScenarios; } } const detail = errorLines.find((line) => line.startsWith("Error:")) || errorLines.at(-1) || "scenario command failed"; throw new Error( `cannot read frozen upgrade-survivor scenarios from ${assertionsFile}: ${detail}`, ); } let parsed: unknown; try { parsed = JSON.parse(result.stdout); } catch { throw new Error( `cannot read frozen upgrade-survivor scenarios from ${assertionsFile}: list-scenarios did not return JSON`, ); } if ( !Array.isArray(parsed) || parsed.length === 0 || parsed.some( (scenario) => typeof scenario !== "string" || !/^[a-z0-9][a-z0-9-]*$/u.test(scenario), ) || new Set(parsed).size !== parsed.length ) { throw new Error( `cannot read frozen upgrade-survivor scenarios from ${assertionsFile}: list-scenarios returned an invalid catalog`, ); } return parsed; } function filterUpgradeSurvivorScenariosForTarget( scenarios: string[], targetRoot: string | undefined, allowExecutableContract: boolean, frozenTarget?: InertTargetContract, ): string[] { if (!targetRoot && !frozenTarget) { return scenarios; } const targetOwnedScenarios = scenarios.filter( (scenario) => !isTrustedHarnessOwnedUpgradeSurvivorScenario(scenario), ); if (targetOwnedScenarios.length === 0) { return scenarios; } const relativePath = "scripts/e2e/lib/upgrade-survivor/assertions.mjs"; if (frozenTarget) { const source = frozenTarget.source.readText(relativePath); const catalog = source === null ? undefined : readInertFrozenScenarioContract(source, targetRoot, frozenTarget); if (!catalog) { throw new Error(`unrecognized required inert scenario catalog: ${relativePath}`); } return scenarios.filter( (scenario) => isTrustedHarnessOwnedUpgradeSurvivorScenario(scenario) || catalog.includes(scenario), ); } const assertionsFile = resolve(targetRoot!, relativePath); if (!existsSync(assertionsFile)) { return scenarios.filter(isTrustedHarnessOwnedUpgradeSurvivorScenario); } const targetScenarios = readFrozenScenarioContract( assertionsFile, targetRoot!, allowExecutableContract, ); const supportedScenarios = new Set(targetScenarios); return scenarios.filter( (scenario) => isTrustedHarnessOwnedUpgradeSurvivorScenario(scenario) || supportedScenarios.has(scenario), ); } function readTargetMetadata( targetRoot: string | undefined, relativePath: string, frozenTarget?: InertTargetContract, ): string | null { if (frozenTarget) { return frozenTarget.source.readText(relativePath); } const file = resolve(targetRoot!, relativePath); return existsSync(file) ? readFileSync(file, "utf8") : null; } function supportsUpdateFirstHopCompatForTarget( laneName: string, targetRoot: string | undefined, frozenTarget?: InertTargetContract, ): boolean { if (!targetRoot && !frozenTarget) { return true; } // A target that records its own inventory only proves the hops it lists. const inventory = readTargetMetadata( targetRoot, "scripts/lib/update-compat-inventory.json", frozenTarget, ); if ( inventory !== null && !(JSON.parse(inventory).releases as { version: string }[]).some( (release) => updateFirstHopCompatLaneName(release.version) === laneName, ) ) { return false; } const source = readTargetMetadata(targetRoot, "scripts/runtime-postbuild.mts", frozenTarget); if (source === null) { return false; } const startMarker = "const LEGACY_CLI_EXIT_COMPAT_CHUNKS = ["; const start = source.indexOf(startMarker); if (start < 0 || source.lastIndexOf(startMarker) !== start) { return false; } const end = source.indexOf("\n];", start + startMarker.length); if (end < 0) { return false; } const block = source.slice(start, end + 3); return UPDATE_FIRST_HOP_COMPAT_CATALOGS.has(createHash("sha256").update(block).digest("hex")); } function supportsMobilePairingReconnectForTarget( targetRoot: string | undefined, frozenTarget?: InertTargetContract, ): boolean { if (!targetRoot && !frozenTarget) { return true; } const source = readTargetMetadata(targetRoot, "src/gateway/node-command-policy.ts", frozenTarget); if (source === null) { return false; } return ( IOS_WATCH_RELAY_COMMANDS.every((command) => source.includes(command)) && source.includes('platformId === "ios"') && source.includes('normalizeDeviceMetadataForPolicy(node?.deviceFamily) === "iphone"') && source.includes("...watchRelayCommands") ); } function supportsCorruptPluginUpdateForTarget( targetRoot: string | undefined, frozenTarget?: InertTargetContract, ): boolean { return ( (!targetRoot && !frozenTarget) || readTargetMetadata( targetRoot, "src/cli/update-cli/update-command-plugin-preflight.ts", frozenTarget, ) !== null ); } function expandedUpgradeSurvivorLaneName( poolLaneName: string, baselineSpec: string | undefined, scenario: string | undefined, ) { const suffixParts = [ baselineSpec ? sanitizeLaneNameSuffix(baselineSpec) : "", scenario && scenario !== "base" ? sanitizeLaneNameSuffix(scenario) : "", ].filter(Boolean); const suffix = suffixParts.join("-"); return suffix ? `${poolLaneName}-${suffix}` : poolLaneName; } function expandUpgradeSurvivorBaselineLanes( poolLanes: DockerE2eLane[], rawBaselineSpecs: string | undefined, targetRoot: string | undefined, rawScenarios = "", allowExecutableContract = false, frozenTarget?: InertTargetContract, ): UpgradeSurvivorExpansion { const hasUpgradeSurvivorLane = poolLanes.some( (poolLane) => poolLane.name === "published-upgrade-survivor" || poolLane.name === "update-migration", ); if (!hasUpgradeSurvivorLane) { return { lanes: poolLanes, omittedLaneNames: [] }; } const baselineSpecs = parseUpgradeSurvivorBaselineSpecs(rawBaselineSpecs); baselineSpecs.forEach(assertSupportedUpgradeSurvivorBaselineSpec); // Trusted-current planners may know scenarios that a frozen target's Docker // harness cannot seed or assert. Filter by the selected tree's concrete // harness contract so validation never schedules an impossible target lane. const configuredScenarios = parseUpgradeSurvivorScenarios(rawScenarios); const requestedScenarios = configuredScenarios.length > 0 ? configuredScenarios : ["base"]; const supportedScenarios = filterUpgradeSurvivorScenariosForTarget( requestedScenarios, targetRoot, allowExecutableContract, frozenTarget, ); const supportedScenarioSet = new Set(supportedScenarios); const unsupportedScenarios = targetRoot || frozenTarget ? requestedScenarios.filter((scenario) => !supportedScenarioSet.has(scenario)) : []; const scenarios = configuredScenarios.length > 0 ? supportedScenarios : []; const matrixBaselines = baselineSpecs.length > 0 ? baselineSpecs : [undefined]; const survivorLanes = poolLanes.filter( (poolLane) => poolLane.name === "published-upgrade-survivor" || poolLane.name === "update-migration", ); const omittedLaneNames = survivorLanes.flatMap((poolLane) => matrixBaselines.flatMap((baselineSpec) => unsupportedScenarios .filter((scenario) => supportsUpgradeSurvivorScenarioAtBaseline(scenario, baselineSpec)) .map((scenario) => expandedUpgradeSurvivorLaneName(poolLane.name, baselineSpec, scenario)), ), ); if (supportedScenarios.length === 0 && unsupportedScenarios.length > 0) { return { lanes: poolLanes.filter( (poolLane) => poolLane.name !== "published-upgrade-survivor" && poolLane.name !== "update-migration", ), omittedLaneNames, }; } if (baselineSpecs.length === 0 && scenarios.length === 0) { return { lanes: poolLanes, omittedLaneNames }; } return { lanes: poolLanes.flatMap((poolLane) => { if (poolLane.name !== "published-upgrade-survivor" && poolLane.name !== "update-migration") { return [poolLane]; } const matrixScenarios = scenarios.length > 0 ? scenarios : [undefined]; return matrixBaselines.flatMap((baselineSpec) => matrixScenarios .filter((scenario) => supportsUpgradeSurvivorScenarioAtBaseline(scenario, baselineSpec)) .map((scenario) => { const name = expandedUpgradeSurvivorLaneName(poolLane.name, baselineSpec, scenario); const suffix = name.slice(poolLane.name.length + 1); const commandPrefix = [ `OPENCLAW_UPGRADE_SURVIVOR_ARTIFACT_DIR="$PWD/.artifacts/upgrade-survivor/${name}"`, baselineSpec ? `OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC=${shellQuote(baselineSpec)}` : "", scenario ? `OPENCLAW_UPGRADE_SURVIVOR_SCENARIO=${shellQuote(scenario)}` : "", ] .filter(Boolean) .join(" "); return Object.assign({}, poolLane, { cacheKey: poolLane.cacheKey ? suffix ? `${poolLane.cacheKey}-${suffix}` : poolLane.cacheKey : name, command: commandPrefix ? `${commandPrefix} ${poolLane.command}` : poolLane.command, name, }); }), ); }), omittedLaneNames, }; } function dedupeLanes(poolLanes: DockerE2eLane[]): DockerE2eLane[] { const byName = new Map(); for (const poolLane of poolLanes) { if (!byName.has(poolLane.name)) { byName.set(poolLane.name, poolLane); } } return [...byName.values()]; } function selectNamedLanes( poolLanes: DockerE2eLane[], selectedNames: string[], label: string, ): DockerE2eLane[] { const byName = new Map(poolLanes.map((poolLane) => [poolLane.name, poolLane])); const missing = selectedNames.filter((name) => !byName.has(name)); if (missing.length > 0) { throw new Error( `${label} unknown lane(s): ${missing.join(", ")}. Available lanes: ${[...byName.keys()] .toSorted((a, b) => a.localeCompare(b)) .join(", ")}`, ); } return selectedNames.map((name) => byName.get(name)!); } export function parseLiveMode(raw: unknown): LiveMode { const mode = raw || "all"; if (mode === "all" || mode === "skip" || mode === "only") { return mode; } throw new Error( `OPENCLAW_DOCKER_ALL_LIVE_MODE must be one of: all, skip, only. Got: ${JSON.stringify(raw)}`, ); } export function parseProfile(raw: unknown): DockerProfile { const profile = raw || DEFAULT_PROFILE; if (profile === DEFAULT_PROFILE || profile === RELEASE_PATH_PROFILE) { return profile; } throw new Error( `OPENCLAW_DOCKER_ALL_PROFILE must be one of: ${DEFAULT_PROFILE}, ${RELEASE_PATH_PROFILE}. Got: ${JSON.stringify(raw)}`, ); } function applyLiveMode(poolLanes: DockerE2eLane[], mode: LiveMode): DockerE2eLane[] { if (mode === "all") { return poolLanes; } return poolLanes.filter((poolLane) => (mode === "only" ? poolLane.live : !poolLane.live)); } export function laneWeight(poolLane: DockerE2eLane): number { return Math.max(1, poolLane.weight ?? 1); } export function laneResources(poolLane: DockerE2eLane): string[] { return [...new Set(["docker", ...(poolLane.resources ?? [])])]; } export function laneSummary(poolLane: DockerE2eLane): string { const resources = laneResources(poolLane).join(","); const timeout = poolLane.timeoutMs ? ` timeout=${Math.round(poolLane.timeoutMs / 1000)}s` : ""; const noOutputTimeout = poolLane.noOutputTimeoutMs ? ` no-output=${Math.round(poolLane.noOutputTimeoutMs / 1000)}s` : ""; const cache = poolLane.cacheKey ? ` cache=${poolLane.cacheKey}` : ""; const image = poolLane.e2eImageKind ? ` image=${poolLane.e2eImageKind}` : ""; const state = poolLane.stateScenario ? ` state=${poolLane.stateScenario}` : ""; return `${poolLane.name}(w=${laneWeight(poolLane)} r=${resources}${timeout}${noOutputTimeout}${cache}${image}${state})`; } export function lanesNeedE2eImageKind( poolLanes: DockerE2eLane[], kind: DockerE2eImageKind, ): boolean { return poolLanes.some((poolLane) => poolLane.e2eImageKind === kind); } export function lanesNeedOpenClawPackage(poolLanes: DockerE2eLane[]): boolean { return poolLanes.some((poolLane) => poolLane.needsPackage || poolLane.e2eImageKind); } export function findLaneByName(name: string): DockerE2eLane | undefined { return dedupeLanes( expandUpgradeSurvivorBaselineLanes( [ ...allReleasePathLanes({ includeOpenWebUI: true }), ...publicInstallerLanes, fleetCacheLane, ...mainLanes, ...tailLanes, ], process.env.OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPECS, undefined, process.env.OPENCLAW_UPGRADE_SURVIVOR_SCENARIOS, ).lanes, ).find((poolLane) => poolLane.name === name); } function laneCredentialRequirements(poolLane: DockerE2eLane): string[] { const resources = laneResources(poolLane); const credentials: string[] = []; if (poolLane.name === "install-e2e-openai") { credentials.push("openai"); } if (poolLane.name === "install-e2e-anthropic") { credentials.push("anthropic"); } if (resources.includes("live:openai")) { credentials.push("openai"); } if (resources.includes("live:codex")) { credentials.push("codex"); } if (resources.includes("live:claude")) { credentials.push(poolLane.name === "live-anthropic-cache" ? "anthropic-api-key" : "anthropic"); } if (resources.includes("live:droid")) { credentials.push("factory"); } if (resources.includes("live:gemini")) { credentials.push("gemini"); } if (resources.includes("live:opencode")) { credentials.push("opencode"); } if (resources.includes("live:telegram")) { credentials.push("telegram"); } return credentials; } function unique(values: Array): T[] { return [...new Set(values.filter((value): value is T => value != null))]; } function upgradeSurvivorScenarioForLane(poolLane: DockerE2eLane): string | undefined { if (!poolLane.upgradeSurvivorScenario) { return undefined; } const match = /(?:^|\s)OPENCLAW_UPGRADE_SURVIVOR_SCENARIO=(?:'([^']+)'|"([^"]+)"|([^\s]+))/u.exec( poolLane.command, ); return match?.[1] ?? match?.[2] ?? match?.[3] ?? poolLane.upgradeSurvivorScenario; } function upgradeSurvivorBaselineVersionForLane(poolLane: DockerE2eLane): string | null { const match = /(?:^|\s)OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC=(?:'([^']+)'|"([^"]+)"|([^\s]+))/u.exec( poolLane.command, ); const spec = match?.[1] ?? match?.[2] ?? match?.[3]; return /(?:^|\/|@)(\d{4}\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)$/u.exec(spec ?? "")?.[1] ?? null; } function legacyOperatorProviderPackages( targetRoot?: string, frozenTarget?: InertTargetContract, ): string[] { const catalogPath = "scripts/lib/official-external-provider-catalog.json"; const text = targetRoot || frozenTarget ? readTargetMetadata(targetRoot, catalogPath, frozenTarget) : null; // Older candidates without the external catalog still carry their bundled providers. const catalog: unknown = targetRoot || frozenTarget ? text === null ? { entries: [] } : JSON.parse(text) : officialExternalProviderCatalog; if (!isRecord(catalog) || !Array.isArray(catalog.entries)) { throw new Error(`invalid candidate provider catalog: ${catalogPath}`); } return catalog.entries.flatMap((entry: unknown) => isRecord(entry) && entry.source === "official" && typeof entry.name === "string" && isRecord(entry.openclaw) && isRecord(entry.openclaw.install) && entry.openclaw.install.npmSpec === entry.name ? [entry.name] : [], ); } export function requiredPrepublishPluginPackagesForLanes( poolLanes: DockerE2eLane[], targetRoot?: string, frozenTarget?: InertTargetContract, ): string[] { const configuredChannelIds = new Set(); const requiredPackages = new Set(); let legacyOperatorProviders: string[] | undefined; for (const poolLane of poolLanes) { for (const packageName of poolLane.prepublishPluginPackages ?? []) { requiredPackages.add(packageName); } const scenario = upgradeSurvivorScenarioForLane(poolLane); if ( !scenario || scenario === "abandoned-update" || scenario === "custom-plugin-siblings" || scenario === "projects-doctor" || scenario === "channel-owner-policy" || scenario === "projects-startup-migration" || scenario === "workshop-doctor-recovery" || scenario === "update-report-recovery" || scenario === "dreaming-cron-doctor" ) { continue; } if (scenario === "legacy-operator-state") { requiredPackages.add("@openclaw/codex"); requiredPackages.add("@openclaw/discord"); requiredPackages.add("@openclaw/duckduckgo-plugin"); // The baseline authors an allowlist from its enabled bundled inventory. // Supply candidate providers once, even when several baselines share the registry. legacyOperatorProviders ??= legacyOperatorProviderPackages(targetRoot, frozenTarget); for (const packageName of legacyOperatorProviders) { requiredPackages.add(packageName); } continue; } for (const packageName of UPGRADE_SURVIVOR_RUNTIME_COMPANION_PACKAGES) { requiredPackages.add(packageName); } const steps = resolveUpgradeSurvivorConfigStepsForBaseline( scenario, upgradeSurvivorBaselineVersionForLane(poolLane), ); for (const step of steps) { for (const packageName of step.prepublishPluginPackages ?? []) { requiredPackages.add(packageName); } if (step.argv[0] !== "config" || step.argv[1] !== "set") { continue; } const configPaths: string[] = step.argv[2] === "--batch-json" ? JSON.parse(step.argv[3] ?? "").map((entry: { path: string }) => entry.path) : [step.argv[2] ?? ""]; for (const configPath of configPaths) { const channelId = /^channels\.([a-z0-9][a-z0-9-]*)$/u.exec(configPath)?.[1]; if (channelId) { configuredChannelIds.add(channelId); } } } } for (const packageName of (officialExternalChannelCatalog.entries ?? []) .filter((entry) => { const channelId = entry.openclaw?.channel?.id; const install = entry.openclaw?.install; return ( typeof entry.name === "string" && configuredChannelIds.has(channelId) && install?.npmSpec === entry.name ); }) .map((entry) => entry.name)) { requiredPackages.add(packageName); } return [...requiredPackages].toSorted((a, b) => a.localeCompare(b)); } function buildPlanJson(params: { includeOpenWebUI: boolean; omittedUnsupportedLaneNames: string[]; orderedLanes: DockerE2eLane[]; orderedTailLanes: DockerE2eLane[]; profile: string; releaseChunk: string; releaseProfile: DockerE2eReleaseProfile; selectedLaneNames: string[]; targetRoot?: string; frozenTarget?: InertTargetContract; }) { const scheduledLanes = [...params.orderedLanes, ...params.orderedTailLanes]; const imageKinds = unique(scheduledLanes.map((poolLane) => poolLane.e2eImageKind)).toSorted( (a, b) => a.localeCompare(b), ); const requiredPrepublishPluginPackages = requiredPrepublishPluginPackagesForLanes( scheduledLanes, params.targetRoot, params.frozenTarget, ); return { chunk: params.releaseChunk || undefined, credentials: unique(scheduledLanes.flatMap(laneCredentialRequirements)).toSorted((a, b) => a.localeCompare(b), ), imageKinds, includeOpenWebUI: params.includeOpenWebUI, lanes: scheduledLanes.map((poolLane) => ({ command: poolLane.command, imageKind: poolLane.e2eImageKind, live: poolLane.live, name: poolLane.name, noOutputTimeoutMs: poolLane.noOutputTimeoutMs, resources: laneResources(poolLane), stateScenario: poolLane.stateScenario, timeoutMs: poolLane.timeoutMs, weight: laneWeight(poolLane), })), mainLanes: params.orderedLanes.map((poolLane) => poolLane.name), omittedUnsupportedLanes: params.omittedUnsupportedLaneNames, requiredPrepublishPluginPackages, needs: { bareImage: imageKinds.includes("bare"), e2eImage: imageKinds.length > 0, functionalImage: imageKinds.includes("functional"), liveImage: scheduledLanes.some((poolLane) => poolLane.needsLiveImage), package: lanesNeedOpenClawPackage(scheduledLanes), prepublishPluginRegistry: requiredPrepublishPluginPackages.length > 0, }, profile: params.profile, releaseProfile: params.releaseProfile, selectedLanes: params.selectedLaneNames, tailLanes: params.orderedTailLanes.map((poolLane) => poolLane.name), version: 1, }; } export function resolveDockerE2ePlan(options: DockerE2ePlanOptions) { const releaseProfile = normalizeReleaseProfile(options.releaseProfile); const upgradeSurvivorBaselines = options.upgradeSurvivorBaselines ?? ""; const upgradeSurvivorScenarios = options.upgradeSurvivorScenarios ?? ""; const unexpandedSelectableLanes = dedupeLanes([ ...allReleasePathLanes({ includeOpenWebUI: options.includeOpenWebUI, releaseProfile: "full", }), ...publicInstallerLanes, fleetCacheLane, ...mainLanes, ...tailLanes, ]); const omittedUnsupportedLaneNames = new Set(); const expandRequestedSurvivorLanes = (poolLanes: DockerE2eLane[]) => { const expansion = expandUpgradeSurvivorBaselineLanes( poolLanes, upgradeSurvivorBaselines, options.upgradeSurvivorTargetRoot, upgradeSurvivorScenarios, options.allowFrozenTargetScenarioOmissions, options.frozenTarget, ); const requestedBaselines = parseUpgradeSurvivorBaselineSpecs(upgradeSurvivorBaselines); if ( poolLanes.some( (lane) => lane.name === "published-upgrade-survivor" || lane.name === "update-migration", ) && parseUpgradeSurvivorScenarios(upgradeSurvivorScenarios).includes("missing-load-path") && requestedBaselines.length > 0 && requestedBaselines.every( (baseline) => !supportsUpgradeSurvivorScenarioAtBaseline("missing-load-path", baseline), ) ) { throw new Error( `missing-load-path has no compatible published baseline in ${upgradeSurvivorBaselines}: the installed updater must admit invalid config before candidate staging.`, ); } for (const laneName of expansion.omittedLaneNames) { omittedUnsupportedLaneNames.add(laneName); } return expansion.lanes; }; const unfilteredSelectableLanes = dedupeLanes( expandUpgradeSurvivorBaselineLanes( unexpandedSelectableLanes, upgradeSurvivorBaselines, undefined, upgradeSurvivorScenarios, ).lanes, ); // Selectable lanes are a target-agnostic lookup catalog. Frozen target // contracts are read only after a survivor lane is actually requested. const releaseLanes = options.selectedLaneNames.length === 0 && options.profile === RELEASE_PATH_PROFILE ? options.planReleaseAll ? expandRequestedSurvivorLanes( allReleasePathLanes({ includeOpenWebUI: options.includeOpenWebUI, releaseProfile }), ) : expandRequestedSurvivorLanes( releasePathChunkLanes(options.releaseChunk, { includeOpenWebUI: options.includeOpenWebUI, releaseProfile, }), ) : undefined; const selectedLanes = options.selectedLaneNames.length > 0 ? options.selectedLaneNames.flatMap((selectedName) => { const unexpandedLane = unexpandedSelectableLanes.find( (poolLane) => poolLane.name === selectedName, ); if (unexpandedLane) { return expandRequestedSurvivorLanes([unexpandedLane]); } const expandedLane = unfilteredSelectableLanes.find( (poolLane) => poolLane.name === selectedName, ); if (expandedLane) { const survivorBaseLane = unexpandedSelectableLanes.find( (poolLane) => (poolLane.name === "published-upgrade-survivor" || poolLane.name === "update-migration") && selectedName.startsWith(`${poolLane.name}-`), ); if (!survivorBaseLane) { return [expandedLane]; } // Exact-row reruns retain the original matrix only to reconstruct the catalog. const targetExpansion = expandUpgradeSurvivorBaselineLanes( [survivorBaseLane], upgradeSurvivorBaselines, options.upgradeSurvivorTargetRoot, upgradeSurvivorScenarios, options.allowFrozenTargetScenarioOmissions, options.frozenTarget, ); const supportedLane = targetExpansion.lanes.find( (poolLane) => poolLane.name === selectedName, ); if (supportedLane) { return [supportedLane]; } omittedUnsupportedLaneNames.add(selectedName); return []; } selectNamedLanes(unfilteredSelectableLanes, [selectedName], "OPENCLAW_DOCKER_ALL_LANES"); return []; }) : undefined; let configuredLanes = selectedLanes ? selectedLanes : releaseLanes ? applyLiveMode(releaseLanes, options.liveMode) : options.liveMode === "only" ? applyLiveMode([...mainLanes, ...tailLanes], options.liveMode) : applyLiveMode(mainLanes, options.liveMode); if (options.allowFrozenTargetScenarioOmissions) { const unsupportedLaneRules = [ { matches: (lane: DockerE2eLane) => isUpdateFirstHopCompatLane(lane.name), supported: (lane: DockerE2eLane) => supportsUpdateFirstHopCompatForTarget( lane.name, options.upgradeSurvivorTargetRoot, options.frozenTarget, ), }, { matches: (lane: DockerE2eLane) => lane.name.includes("mobile-pairing-reconnect"), supported: () => supportsMobilePairingReconnectForTarget( options.upgradeSurvivorTargetRoot, options.frozenTarget, ), }, { matches: (lane: DockerE2eLane) => lane.name === "update-corrupt-plugin", supported: () => supportsCorruptPluginUpdateForTarget( options.upgradeSurvivorTargetRoot, options.frozenTarget, ), }, ]; configuredLanes = configuredLanes.filter((lane) => { const rule = unsupportedLaneRules.find((entry) => entry.matches(lane)); if (!rule || rule.supported(lane)) { return true; } omittedUnsupportedLaneNames.add(lane.name); return false; }); } if (omittedUnsupportedLaneNames.size > 0 && !options.allowFrozenTargetScenarioOmissions) { throw new Error("unsupported frozen target lanes require authorized scenario omissions"); } const configuredTailLanes = selectedLanes || releaseLanes ? [] : options.liveMode === "only" ? [] : applyLiveMode(tailLanes, options.liveMode); const orderedLanes = options.orderLanes(configuredLanes, options.timingStore); const orderedTailLanes = options.orderLanes(configuredTailLanes, options.timingStore); return { omittedUnsupportedLaneNames: [...omittedUnsupportedLaneNames], orderedLanes, orderedTailLanes, plan: buildPlanJson({ includeOpenWebUI: options.includeOpenWebUI, omittedUnsupportedLaneNames: [...omittedUnsupportedLaneNames], orderedLanes, orderedTailLanes, profile: options.profile, releaseChunk: options.releaseChunk, releaseProfile, selectedLaneNames: options.selectedLaneNames, targetRoot: options.upgradeSurvivorTargetRoot, frozenTarget: options.frozenTarget, }), scheduledLanes: [...orderedLanes, ...orderedTailLanes], }; }