mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 09:39:25 +00:00
Adds named storage locations as a generic, pluggable capability, with backup as its first consumer. - Core storage owner (src/storage): storage.locations config, a location marker that binds identity (runtime never creates it, so unplugged disks and different disks at the same path are refused), client-side streaming encryption (scrypt key from a SecretRef passphrase, per-object HKDF keys, AES-256-GCM segments), and a built-in filesystem provider for external disks and mounts. - Plugin SDK: api.registerStorageProvider plus manifest contracts.storageProviders; providers move opaque bytes only. - Bundled cloudflare plugin: an r2 provider over the S3 API with conditional writes and bounded multipart uploads; auto-enabled when a location uses provider "r2". - Backups: backup create --to <location> with verified archives, UTC retention, list/verify/restore --from, Gateway-owned offsite schedules (installed Git schedules unchanged), per-installation namespace claims fenced at publication and deletion, backup record for external jobs, backup.status RPC, Doctor/status hints, and a Systems page Backups section. No config or state migration; the storage section is new and optional. Proof: live R2 and mounted-disk round trips, namespace takeover trace, and a published 2026.9.7 upgrade cell with an existing Git backup schedule.
1115 lines
45 KiB
TypeScript
1115 lines
45 KiB
TypeScript
// 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<string, DockerE2eLane>();
|
|
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<T>(values: Array<T | null | undefined>): 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<string>();
|
|
const requiredPackages = new Set<string>();
|
|
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 === "backup-schedule" ||
|
|
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<string>();
|
|
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],
|
|
};
|
|
}
|