mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 13:26:04 -06:00
Merge remote-tracking branch 'origin/main' into pr-93985-prep
* origin/main: (3706 commits) refactor(ui): unify filtered session list ownership (#117158) refactor(agents): consolidate context budgets and compaction recovery (#117149) fix(net-policy): redact sig and x-* auth params in URLs and bodies (#116957) refactor(agents): remove duplicate generated-media delivery fallbacks (#117150) refactor(reply): unify turn lifecycle state ownership (#117145) fix(ui): preserve emoji agent avatar initials (#104912) refactor(auto-reply): unify command and directive ownership (#117143) refactor(plugins): consolidate descriptor and startup ownership (#117146) fix(messages): reply actions leak citation markers and reply/poll answers draw the no-reply fallback (#116909) fix(slack): let durable ingress retry transient thread lookups (#117135) refactor(doctor): consolidate shipped state migration ownership (#117142) fix(ai): Codex stream shows internal parser text on a malformed frame (#116966) fix: guard every migrated session accessor path (#117140) fix(terminal): measure unicode display width consistently (#117062) fix(openai): clear ChatGPT SSE fallback per session (#117123) fix(mistral): reject incomplete streamed tool terminals (#117137) fix(ai): preserve structured chat content and refusals (#117136) fix(doctor): persist normalized agent roster (#117115) fix(signal): avoid replay after ambiguous quote delivery errors (#117134) fix(cron): reject disabled delivery accounts when scheduling (#116899) ...
This commit is contained in:
+335
-59
@@ -1,5 +1,5 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { expectDefined } from "../packages/normalization-core/src/expect.js";
|
||||
@@ -10,6 +10,19 @@ const ROOT = path.resolve(HERE, "..");
|
||||
const ANDROID_MAIN_ROOT = path.join(ROOT, "apps", "android", "app", "src", "main");
|
||||
const RESOURCE_ROOT = path.join(ANDROID_MAIN_ROOT, "res");
|
||||
const SOURCE_ROOT = path.join(ANDROID_MAIN_ROOT, "java");
|
||||
const ANDROID_PLAY_SOURCE_ROOT = path.join(ROOT, "apps", "android", "app", "src", "play", "java");
|
||||
const ANDROID_THIRD_PARTY_ROOT = path.join(ROOT, "apps", "android", "app", "src", "thirdParty");
|
||||
const ANDROID_THIRD_PARTY_RESOURCE_ROOT = path.join(ANDROID_THIRD_PARTY_ROOT, "res");
|
||||
const ANDROID_THIRD_PARTY_SOURCE_ROOT = path.join(ANDROID_THIRD_PARTY_ROOT, "java");
|
||||
const ANDROID_WEAR_MAIN_ROOT = path.join(ROOT, "apps", "android", "wear", "src", "main");
|
||||
const WEAR_RESOURCE_ROOT = path.join(ANDROID_WEAR_MAIN_ROOT, "res");
|
||||
const WEAR_SOURCE_ROOT = path.join(ANDROID_WEAR_MAIN_ROOT, "java");
|
||||
const ANDROID_SOURCE_ROOTS = [
|
||||
SOURCE_ROOT,
|
||||
ANDROID_PLAY_SOURCE_ROOT,
|
||||
ANDROID_THIRD_PARTY_SOURCE_ROOT,
|
||||
WEAR_SOURCE_ROOT,
|
||||
] as const;
|
||||
const INVENTORY_PATH = path.join(ROOT, "apps", ".i18n", "native-source.json");
|
||||
const ARTIFACT_ROOT = path.join(ROOT, "apps", ".i18n", "native");
|
||||
const TOOL_DISPLAY_PATH = path.join(
|
||||
@@ -50,6 +63,14 @@ const FORMAT_RE = /%\d+\$[a-z]/giu;
|
||||
const INVALID_APOSTROPHE_RE = /(?:'|(?<!\\)')/u;
|
||||
const GENERATED_HEADER = " <!-- Generated by scripts/android-app-i18n.ts. -->";
|
||||
const GENERATED_KOTLIN_HEADER = "// Generated by scripts/android-app-i18n.ts. Do not edit.";
|
||||
const THIRD_PARTY_STRINGS_FILE = "accessibility_strings.xml";
|
||||
const THIRD_PARTY_STRINGS_REPO_PATH =
|
||||
"apps/android/app/src/thirdParty/res/values/accessibility_strings.xml";
|
||||
const THIRD_PARTY_GENERATED_RESOURCE_RE =
|
||||
/^apps\/android\/app\/src\/thirdParty\/res\/values-[^/]+\/accessibility_strings\.xml$/;
|
||||
const WEAR_STRINGS_REPO_PATH = "apps/android/wear/src/main/res/values/strings.xml";
|
||||
const WEAR_GENERATED_RESOURCE_RE =
|
||||
/^apps\/android\/wear\/src\/main\/res\/values-[^/]+\/strings\.xml$/;
|
||||
|
||||
type NativeInventoryEntry = {
|
||||
id: string;
|
||||
@@ -72,6 +93,12 @@ type ResourceString = {
|
||||
value: string;
|
||||
};
|
||||
|
||||
const GENERATED_TRANSLATION_LINT_IGNORES = [
|
||||
"Typos",
|
||||
"TypographyDashes",
|
||||
"TypographyEllipsis",
|
||||
] as const;
|
||||
|
||||
type TranslationContradiction = {
|
||||
locale: string;
|
||||
selected: string;
|
||||
@@ -79,7 +106,7 @@ type TranslationContradiction = {
|
||||
translations: string[];
|
||||
};
|
||||
|
||||
type GeneratedCatalog = {
|
||||
export type GeneratedCatalog = {
|
||||
contradictions: TranslationContradiction[];
|
||||
kotlin: string;
|
||||
resources: Map<string, string>;
|
||||
@@ -217,7 +244,11 @@ export function renderAndroidResourceValue(source: string, translated: string):
|
||||
}
|
||||
rendered += translated.slice(cursor).replaceAll("%", "%%");
|
||||
}
|
||||
return rendered
|
||||
return escapeAndroidResourceValue(rendered);
|
||||
}
|
||||
|
||||
export function escapeAndroidResourceValue(value: string): string {
|
||||
return value
|
||||
.replaceAll("\\", "\\\\")
|
||||
.replaceAll("\n", "\\n")
|
||||
.replaceAll("'", "\\'")
|
||||
@@ -248,6 +279,19 @@ function parseStrings(source: string): ResourceString[] {
|
||||
}));
|
||||
}
|
||||
|
||||
function withGeneratedTranslationLintIgnores(attrs: string): string {
|
||||
const existing = attrs.match(/\btools:ignore\s*=\s*"([^"]*)"/u);
|
||||
const ignores = [
|
||||
...(existing?.[1]
|
||||
?.split(",")
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean) ?? []),
|
||||
...GENERATED_TRANSLATION_LINT_IGNORES,
|
||||
];
|
||||
const rendered = `tools:ignore="${[...new Set(ignores)].join(",")}"`;
|
||||
return existing ? attrs.replace(existing[0], rendered) : `${attrs} ${rendered}`;
|
||||
}
|
||||
|
||||
function parseArrays(source: string): Map<string, string[]> {
|
||||
return new Map(
|
||||
[...source.matchAll(ARRAY_RE)].map((match) => [
|
||||
@@ -259,8 +303,12 @@ function parseArrays(source: string): Map<string, string[]> {
|
||||
);
|
||||
}
|
||||
|
||||
async function readStrings(locale: string): Promise<Map<string, ResourceString>> {
|
||||
const source = await readFile(path.join(RESOURCE_ROOT, locale, "strings.xml"), "utf8");
|
||||
async function readStrings(
|
||||
locale: string,
|
||||
resourceRoot = RESOURCE_ROOT,
|
||||
fileName = "strings.xml",
|
||||
): Promise<Map<string, ResourceString>> {
|
||||
const source = await readFile(path.join(resourceRoot, locale, fileName), "utf8");
|
||||
return new Map(parseStrings(source).map((entry) => [entry.key, entry]));
|
||||
}
|
||||
|
||||
@@ -285,6 +333,10 @@ async function readAndroidSource(
|
||||
return sources;
|
||||
}
|
||||
|
||||
async function readAllAndroidSource(): Promise<Array<{ path: string; source: string }>> {
|
||||
return (await Promise.all(ANDROID_SOURCE_ROOTS.map((root) => readAndroidSource(root)))).flat();
|
||||
}
|
||||
|
||||
type AndroidResourceReferenceSource = {
|
||||
path: string;
|
||||
source: string;
|
||||
@@ -481,6 +533,11 @@ const ALLOWED_UI_LITERALS = new Map<string, ReadonlySet<string>>([
|
||||
"apps/android/app/src/main/java/ai/openclaw/app/ui/VoiceScreen.kt",
|
||||
new Set(["${normalized.takeUtf16Safe(87)}..."]),
|
||||
],
|
||||
[
|
||||
"apps/android/app/src/main/java/ai/openclaw/app/ui/SidebarShell.kt",
|
||||
// Compose animation labels are tooling identifiers, not rendered copy.
|
||||
new Set(["sidebar-content-translation"]),
|
||||
],
|
||||
[
|
||||
"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatCommandControls.kt",
|
||||
new Set(["/$name", "help"]),
|
||||
@@ -489,6 +546,11 @@ const ALLOWED_UI_LITERALS = new Map<string, ReadonlySet<string>>([
|
||||
"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageActions.kt",
|
||||
new Set([">", "> $line"]),
|
||||
],
|
||||
[
|
||||
"apps/android/wear/src/main/java/ai/openclaw/wear/WearScreens.kt",
|
||||
// Compose animation labels are tooling identifiers, not rendered copy.
|
||||
new Set(["voice-swipe-hint", "voice-swipe-hint-offset"]),
|
||||
],
|
||||
]);
|
||||
|
||||
function isAllowedUiLiteral(repoPath: string, source: string): boolean {
|
||||
@@ -503,7 +565,10 @@ function shouldScanUiLiterals(repoPath: string): boolean {
|
||||
if (repoPath.endsWith("/i18n/NativeStringResources.kt")) {
|
||||
return false;
|
||||
}
|
||||
if (repoPath.endsWith("/AndroidScreenshotFixture.kt")) {
|
||||
if (
|
||||
repoPath.endsWith("/AndroidScreenshotFixture.kt") ||
|
||||
repoPath.endsWith("/WearScreenshotMode.kt")
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (repoPath.endsWith("/ui/design/ClawComponents.kt")) {
|
||||
@@ -514,6 +579,8 @@ function shouldScanUiLiterals(repoPath: string): boolean {
|
||||
}
|
||||
return (
|
||||
repoPath.includes("/ui/") ||
|
||||
repoPath.endsWith("/accessibility/AccessibilityDevActivity.kt") ||
|
||||
repoPath.includes("/wear/src/main/java/ai/openclaw/wear/") ||
|
||||
repoPath.endsWith("/MainActivity.kt") ||
|
||||
repoPath.endsWith("/NodeRuntime.kt") ||
|
||||
repoPath.endsWith("/PermissionRequester.kt") ||
|
||||
@@ -972,7 +1039,7 @@ export function findUnlocalizedAndroidUiLiterals(
|
||||
);
|
||||
}
|
||||
|
||||
function findInvalidResourceSyntax(strings: Map<string, ResourceString>): string[] {
|
||||
function findInvalidResourceSyntax(strings: ReadonlyMap<string, ResourceString>): string[] {
|
||||
return [...strings]
|
||||
.filter(([, entry]) => {
|
||||
const trimmed = entry.rawValue.trim();
|
||||
@@ -1035,12 +1102,77 @@ async function readArtifacts(): Promise<Map<string, NativeArtifactEntry[]>> {
|
||||
);
|
||||
}
|
||||
|
||||
function translationsBySource(
|
||||
artifactEntries: readonly NativeArtifactEntry[],
|
||||
): Map<string, string[]> {
|
||||
const translations = new Map<string, string[]>();
|
||||
for (const entry of artifactEntries) {
|
||||
const values = translations.get(entry.source) ?? [];
|
||||
values.push(entry.translated);
|
||||
translations.set(entry.source, values);
|
||||
}
|
||||
return translations;
|
||||
}
|
||||
|
||||
function artifactEntriesById(
|
||||
artifactEntries: readonly NativeArtifactEntry[],
|
||||
): Map<string, NativeArtifactEntry> {
|
||||
return new Map(artifactEntries.map((entry) => [entry.id, entry]));
|
||||
}
|
||||
|
||||
export function selectExactArtifactTranslation(
|
||||
source: string,
|
||||
inventoryId: string,
|
||||
artifactEntries: ReadonlyMap<string, { source: string; translated: string }>,
|
||||
): string {
|
||||
const artifactEntry = artifactEntries.get(inventoryId);
|
||||
if (!artifactEntry) {
|
||||
return source;
|
||||
}
|
||||
if (artifactEntry.source !== source) {
|
||||
throw new Error(
|
||||
`Wear translation source drift for ${inventoryId}: ${JSON.stringify(artifactEntry.source)} != ${JSON.stringify(source)}`,
|
||||
);
|
||||
}
|
||||
return artifactEntry.translated || source;
|
||||
}
|
||||
|
||||
function localizeManualStrings(
|
||||
base: ReadonlyMap<string, ResourceString>,
|
||||
inventoryBySource: ReadonlyMap<string, NativeInventoryEntry>,
|
||||
artifactEntries: ReadonlyMap<string, NativeArtifactEntry>,
|
||||
surface: string,
|
||||
): ResourceString[] {
|
||||
return [...base.values()].map((entry) => {
|
||||
const source = decodeAndroidResourceValue(entry.rawValue);
|
||||
const translatable = !/\btranslatable\s*=\s*"false"/u.test(entry.attrs);
|
||||
const inventoryEntry = inventoryBySource.get(source);
|
||||
if (translatable && !inventoryEntry) {
|
||||
throw new Error(
|
||||
`${surface} string is missing from native inventory: ${JSON.stringify(source)}`,
|
||||
);
|
||||
}
|
||||
const value =
|
||||
translatable && inventoryEntry
|
||||
? selectExactArtifactTranslation(source, inventoryEntry.id, artifactEntries)
|
||||
: source;
|
||||
return {
|
||||
attrs: translatable ? withGeneratedTranslationLintIgnores(entry.attrs) : entry.attrs,
|
||||
key: entry.key,
|
||||
rawValue: `"${escapeAndroidResourceValue(value)}"`,
|
||||
value,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function renderStringsXml(
|
||||
manual: readonly ResourceString[],
|
||||
generated: ReadonlyMap<string, { source: string; value: string }>,
|
||||
): string {
|
||||
const usesToolsNamespace =
|
||||
generated.size > 0 || manual.some((entry) => entry.attrs.includes("tools:"));
|
||||
const lines = [
|
||||
generated.size > 0
|
||||
usesToolsNamespace
|
||||
? '<resources xmlns:tools="http://schemas.android.com/tools">'
|
||||
: "<resources>",
|
||||
];
|
||||
@@ -1057,7 +1189,7 @@ function renderStringsXml(
|
||||
// Translation-memory text intentionally preserves technical tokens and source punctuation,
|
||||
// so Android's English dictionary and typography suggestions do not apply to managed keys.
|
||||
lines.push(
|
||||
` <string name="${key}"${formatted} tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"${renderAndroidResourceValue(entry.source, entry.value)}"</string>`,
|
||||
` <string name="${key}"${withGeneratedTranslationLintIgnores(formatted)}>"${renderAndroidResourceValue(entry.source, entry.value)}"</string>`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1092,16 +1224,36 @@ function renderKotlin(sourceToKey: ReadonlyMap<string, string>): string {
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
async function buildCatalog(): Promise<GeneratedCatalog> {
|
||||
const [inventory, artifacts, localeStrings, sourceFiles, toolDisplaySources] = await Promise.all([
|
||||
export async function buildAndroidAppI18nCatalog(): Promise<GeneratedCatalog> {
|
||||
const [
|
||||
inventory,
|
||||
artifacts,
|
||||
localeStrings,
|
||||
thirdPartyBaseStrings,
|
||||
wearBaseStrings,
|
||||
sourceFiles,
|
||||
toolDisplaySources,
|
||||
] = await Promise.all([
|
||||
readInventory(),
|
||||
readArtifacts(),
|
||||
Promise.all(LOCALES.map(readStrings)),
|
||||
readAndroidSource(),
|
||||
Promise.all(LOCALES.map((locale) => readStrings(locale))),
|
||||
readStrings("values", ANDROID_THIRD_PARTY_RESOURCE_ROOT, THIRD_PARTY_STRINGS_FILE),
|
||||
readStrings("values", WEAR_RESOURCE_ROOT),
|
||||
readAllAndroidSource(),
|
||||
readToolDisplaySources(),
|
||||
]);
|
||||
const baseStrings = expectDefined(localeStrings[0], "English Android string resources");
|
||||
const translatedStrings = localeStrings.slice(1);
|
||||
const wearInventoryBySource = new Map(
|
||||
inventory
|
||||
.filter((entry) => entry.path === WEAR_STRINGS_REPO_PATH)
|
||||
.map((entry) => [entry.source, entry]),
|
||||
);
|
||||
const thirdPartyInventoryBySource = new Map(
|
||||
inventory
|
||||
.filter((entry) => entry.path === THIRD_PARTY_STRINGS_REPO_PATH)
|
||||
.map((entry) => [entry.source, entry]),
|
||||
);
|
||||
const manualBase = [...baseStrings.values()].filter(
|
||||
(entry) => !entry.key.startsWith(MANAGED_PREFIX),
|
||||
);
|
||||
@@ -1133,12 +1285,7 @@ async function buildCatalog(): Promise<GeneratedCatalog> {
|
||||
const contradictions: TranslationContradiction[] = [];
|
||||
for (const [localeIndex, locale] of NATIVE_I18N_LOCALES.entries()) {
|
||||
const manualTranslations = translatedStrings[localeIndex] ?? new Map();
|
||||
const artifactTranslationsBySource = new Map<string, string[]>();
|
||||
for (const entry of artifacts.get(locale) ?? []) {
|
||||
const values = artifactTranslationsBySource.get(entry.source) ?? [];
|
||||
values.push(entry.translated);
|
||||
artifactTranslationsBySource.set(entry.source, values);
|
||||
}
|
||||
const artifactTranslationsBySource = translationsBySource(artifacts.get(locale) ?? []);
|
||||
const generated = new Map<string, { source: string; value: string }>();
|
||||
for (const source of entriesBySource.keys()) {
|
||||
const key = sourceToKey.get(source);
|
||||
@@ -1178,6 +1325,30 @@ async function buildCatalog(): Promise<GeneratedCatalog> {
|
||||
path.join(RESOURCE_ROOT, localeDirectory(locale), "strings.xml"),
|
||||
renderStringsXml(manual, generated),
|
||||
);
|
||||
const wearManual = localizeManualStrings(
|
||||
wearBaseStrings,
|
||||
wearInventoryBySource,
|
||||
artifactEntriesById(artifacts.get(locale) ?? []),
|
||||
"Wear",
|
||||
);
|
||||
resources.set(
|
||||
path.join(WEAR_RESOURCE_ROOT, localeDirectory(locale), "strings.xml"),
|
||||
renderStringsXml(wearManual, new Map()),
|
||||
);
|
||||
const thirdPartyManual = localizeManualStrings(
|
||||
thirdPartyBaseStrings,
|
||||
thirdPartyInventoryBySource,
|
||||
artifactEntriesById(artifacts.get(locale) ?? []),
|
||||
"Android third-party",
|
||||
);
|
||||
resources.set(
|
||||
path.join(
|
||||
ANDROID_THIRD_PARTY_RESOURCE_ROOT,
|
||||
localeDirectory(locale),
|
||||
THIRD_PARTY_STRINGS_FILE,
|
||||
),
|
||||
renderStringsXml(thirdPartyManual, new Map()),
|
||||
);
|
||||
}
|
||||
const generatedBase = new Map<string, { source: string; value: string }>();
|
||||
for (const [source, key] of sourceToKey) {
|
||||
@@ -1226,29 +1397,76 @@ async function buildCatalog(): Promise<GeneratedCatalog> {
|
||||
|
||||
function formatProblems(problems: Array<readonly [string, string[]]>): string {
|
||||
return [
|
||||
"Android app i18n resources are out of sync.",
|
||||
"Android i18n resources are out of sync.",
|
||||
...problems.map(([label, keys]) => `${label}=${keys.join(",") || "none"}`),
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export async function syncAndroidAppI18n(options: { check?: boolean } = {}) {
|
||||
const catalog = await buildCatalog();
|
||||
/**
|
||||
* Managed app rows (native_*) and localized Wear catalogs are owned end-to-end
|
||||
* by the post-merge locale refresh workflow, so a source PR may legitimately
|
||||
* carry stale generated output. Every other delta is real drift.
|
||||
*/
|
||||
function onlyManagedRowsPending(current: string, expected: string): boolean {
|
||||
const currentRows = new Map(parseStrings(current).map((entry) => [entry.key, entry]));
|
||||
const expectedRows = new Map(parseStrings(expected).map((entry) => [entry.key, entry]));
|
||||
for (const [key, entry] of currentRows) {
|
||||
const expectedEntry = expectedRows.get(key);
|
||||
if (!expectedEntry) {
|
||||
if (!key.startsWith(MANAGED_PREFIX)) {
|
||||
return false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (expectedEntry.attrs !== entry.attrs || expectedEntry.rawValue !== entry.rawValue) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
for (const key of expectedRows.keys()) {
|
||||
if (!currentRows.has(key) && !key.startsWith(MANAGED_PREFIX)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function syncAndroidAppI18n(
|
||||
options: { check?: boolean; tolerateManagedPending?: boolean } = {},
|
||||
) {
|
||||
const catalog = await buildAndroidAppI18nCatalog();
|
||||
const drift: string[] = [];
|
||||
let sawUnmanagedDrift = false;
|
||||
for (const [filePath, expected] of catalog.resources) {
|
||||
const current = await readFile(filePath, "utf8").catch(() => "");
|
||||
if (current === expected) {
|
||||
continue;
|
||||
}
|
||||
drift.push(path.relative(ROOT, filePath).split(path.sep).join("/"));
|
||||
const relativeFilePath = path.relative(ROOT, filePath).split(path.sep).join("/");
|
||||
if (
|
||||
options.check &&
|
||||
options.tolerateManagedPending &&
|
||||
(WEAR_GENERATED_RESOURCE_RE.test(relativeFilePath) ||
|
||||
THIRD_PARTY_GENERATED_RESOURCE_RE.test(relativeFilePath) ||
|
||||
(filePath.endsWith("strings.xml") && onlyManagedRowsPending(current, expected)))
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
sawUnmanagedDrift = true;
|
||||
drift.push(relativeFilePath);
|
||||
if (!options.check) {
|
||||
await mkdir(path.dirname(filePath), { recursive: true });
|
||||
await writeFile(filePath, expected);
|
||||
}
|
||||
}
|
||||
const currentKotlin = await readFile(GENERATED_KOTLIN_PATH, "utf8").catch(() => "");
|
||||
if (currentKotlin !== catalog.kotlin) {
|
||||
drift.push(path.relative(ROOT, GENERATED_KOTLIN_PATH).split(path.sep).join("/"));
|
||||
if (!options.check) {
|
||||
await writeFile(GENERATED_KOTLIN_PATH, catalog.kotlin);
|
||||
// The Kotlin map derives 1:1 from the same managed catalog: tolerate it
|
||||
// exactly when every resource delta above was managed-pending.
|
||||
if (!(options.check && options.tolerateManagedPending && !sawUnmanagedDrift)) {
|
||||
drift.push(path.relative(ROOT, GENERATED_KOTLIN_PATH).split(path.sep).join("/"));
|
||||
if (!options.check) {
|
||||
await writeFile(GENERATED_KOTLIN_PATH, catalog.kotlin);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (options.check && drift.length > 0) {
|
||||
@@ -1274,17 +1492,44 @@ export async function syncAndroidAppI18n(options: { check?: boolean } = {}) {
|
||||
}
|
||||
|
||||
export async function verifyAndroidAppI18n() {
|
||||
const [sourceFiles, base, referenceSource] = await Promise.all([
|
||||
readAndroidSource(),
|
||||
const [
|
||||
sourceFiles,
|
||||
base,
|
||||
referenceSource,
|
||||
thirdPartyBase,
|
||||
thirdPartyReferenceSource,
|
||||
wearBase,
|
||||
wearReferenceSource,
|
||||
] = await Promise.all([
|
||||
readAllAndroidSource(),
|
||||
readStrings("values"),
|
||||
readAndroidResourceReferences(),
|
||||
readStrings("values", ANDROID_THIRD_PARTY_RESOURCE_ROOT, THIRD_PARTY_STRINGS_FILE),
|
||||
readAndroidResourceReferences(ANDROID_THIRD_PARTY_ROOT),
|
||||
readStrings("values", WEAR_RESOURCE_ROOT),
|
||||
readAndroidResourceReferences(ANDROID_WEAR_MAIN_ROOT),
|
||||
]);
|
||||
const baseKeys = new Set(base.keys());
|
||||
const thirdPartyBaseKeys = new Set(thirdPartyBase.keys());
|
||||
const wearBaseKeys = new Set(wearBase.keys());
|
||||
const problems: Array<readonly [string, string[]]> = [
|
||||
["English syntax", findInvalidResourceSyntax(base)],
|
||||
["App English syntax", findInvalidResourceSyntax(base)],
|
||||
["Third-party English syntax", findInvalidResourceSyntax(thirdPartyBase)],
|
||||
["Wear English syntax", findInvalidResourceSyntax(wearBase)],
|
||||
];
|
||||
const manualBaseKeys = [...baseKeys].filter((key) => !key.startsWith(MANAGED_PREFIX));
|
||||
problems.push(["English unused", findUnusedAndroidResourceKeys(manualBaseKeys, referenceSource)]);
|
||||
problems.push([
|
||||
"App English unused",
|
||||
findUnusedAndroidResourceKeys(manualBaseKeys, referenceSource),
|
||||
]);
|
||||
problems.push([
|
||||
"Third-party English unused",
|
||||
findUnusedAndroidResourceKeys(thirdPartyBaseKeys, thirdPartyReferenceSource),
|
||||
]);
|
||||
problems.push([
|
||||
"Wear English unused",
|
||||
findUnusedAndroidResourceKeys(wearBaseKeys, wearReferenceSource),
|
||||
]);
|
||||
const uiFindings = sourceFiles.flatMap((file) =>
|
||||
findUnlocalizedAndroidUiLiterals(file.source, file.path),
|
||||
);
|
||||
@@ -1295,44 +1540,75 @@ export async function verifyAndroidAppI18n() {
|
||||
if (problems.some(([, keys]) => keys.length)) {
|
||||
throw new Error(formatProblems(problems));
|
||||
}
|
||||
process.stdout.write(`android-app-i18n: sourceKeys=${baseKeys.size}\n`);
|
||||
process.stdout.write(
|
||||
`android-app-i18n: appSourceKeys=${baseKeys.size} thirdPartySourceKeys=${thirdPartyBaseKeys.size} wearSourceKeys=${wearBaseKeys.size}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function checkAndroidAppI18n() {
|
||||
export async function checkAndroidAppI18n(options: { tolerateManagedPending?: boolean } = {}) {
|
||||
await verifyAndroidAppI18n();
|
||||
await syncAndroidAppI18n({ check: true });
|
||||
const localeStrings = await Promise.all(LOCALES.map(readStrings));
|
||||
await syncAndroidAppI18n({ check: true, ...options });
|
||||
const localeStrings = await Promise.all(LOCALES.map((locale) => readStrings(locale)));
|
||||
const wearLocaleStrings = options.tolerateManagedPending
|
||||
? []
|
||||
: await Promise.all(LOCALES.map((locale) => readStrings(locale, WEAR_RESOURCE_ROOT)));
|
||||
const thirdPartyLocaleStrings = options.tolerateManagedPending
|
||||
? []
|
||||
: await Promise.all(
|
||||
LOCALES.map((locale) =>
|
||||
readStrings(locale, ANDROID_THIRD_PARTY_RESOURCE_ROOT, THIRD_PARTY_STRINGS_FILE),
|
||||
),
|
||||
);
|
||||
const base = expectDefined(localeStrings[0], "English Android string resources");
|
||||
const translations = localeStrings.slice(1);
|
||||
const baseKeys = new Set(base.keys());
|
||||
const problems: Array<readonly [string, string[]]> = translations.flatMap((strings, index) => {
|
||||
const locale = NATIVE_I18N_LOCALES[index];
|
||||
const keys = new Set(strings.keys());
|
||||
const placeholderMismatches = [...base].flatMap(([key, sourceEntry]) => {
|
||||
const translatedEntry = strings.get(key);
|
||||
if (!translatedEntry) {
|
||||
return [];
|
||||
}
|
||||
const expected = [...sourceEntry.rawValue.matchAll(FORMAT_RE)]
|
||||
.map((match) => match[0])
|
||||
.toSorted();
|
||||
const actual = [...translatedEntry.rawValue.matchAll(FORMAT_RE)]
|
||||
.map((match) => match[0])
|
||||
.toSorted();
|
||||
return expected.join("\u0000") === actual.join("\u0000") ? [] : [key];
|
||||
const localeProblems = (
|
||||
surface: string,
|
||||
surfaceBase: ReadonlyMap<string, ResourceString>,
|
||||
translations: ReadonlyArray<ReadonlyMap<string, ResourceString>>,
|
||||
): Array<readonly [string, string[]]> => {
|
||||
const surfaceBaseKeys = new Set(surfaceBase.keys());
|
||||
return translations.flatMap((strings, index) => {
|
||||
const locale = NATIVE_I18N_LOCALES[index];
|
||||
const keys = new Set(strings.keys());
|
||||
const placeholderMismatches = [...surfaceBase].flatMap(([key, sourceEntry]) => {
|
||||
const translatedEntry = strings.get(key);
|
||||
if (!translatedEntry) {
|
||||
return [];
|
||||
}
|
||||
const expected = [...sourceEntry.rawValue.matchAll(FORMAT_RE)]
|
||||
.map((match) => match[0])
|
||||
.toSorted();
|
||||
const actual = [...translatedEntry.rawValue.matchAll(FORMAT_RE)]
|
||||
.map((match) => match[0])
|
||||
.toSorted();
|
||||
return expected.join("\u0000") === actual.join("\u0000") ? [] : [key];
|
||||
});
|
||||
return [
|
||||
[`${surface} ${locale} missing`, [...surfaceBaseKeys].filter((key) => !keys.has(key))],
|
||||
[`${surface} ${locale} extra`, [...keys].filter((key) => !surfaceBaseKeys.has(key))],
|
||||
[`${surface} ${locale} placeholders`, placeholderMismatches],
|
||||
[`${surface} ${locale} syntax`, findInvalidResourceSyntax(strings)],
|
||||
] as const;
|
||||
});
|
||||
return [
|
||||
[`${locale} missing`, [...baseKeys].filter((key) => !keys.has(key))],
|
||||
[`${locale} extra`, [...keys].filter((key) => !baseKeys.has(key))],
|
||||
[`${locale} placeholders`, placeholderMismatches],
|
||||
[`${locale} syntax`, findInvalidResourceSyntax(strings)],
|
||||
] as const;
|
||||
});
|
||||
};
|
||||
const problems = localeProblems("App", base, localeStrings.slice(1));
|
||||
if (thirdPartyLocaleStrings.length > 0) {
|
||||
const thirdPartyBase = expectDefined(
|
||||
thirdPartyLocaleStrings[0],
|
||||
"English Android third-party string resources",
|
||||
);
|
||||
problems.push(
|
||||
...localeProblems("Third-party", thirdPartyBase, thirdPartyLocaleStrings.slice(1)),
|
||||
);
|
||||
}
|
||||
if (wearLocaleStrings.length > 0) {
|
||||
const wearBase = expectDefined(wearLocaleStrings[0], "English Wear string resources");
|
||||
problems.push(...localeProblems("Wear", wearBase, wearLocaleStrings.slice(1)));
|
||||
}
|
||||
if (problems.some(([, keys]) => keys.length)) {
|
||||
throw new Error(formatProblems(problems));
|
||||
}
|
||||
process.stdout.write(
|
||||
`android-app-i18n: keys=${baseKeys.size} locales=${NATIVE_I18N_LOCALES.join(",")}\n`,
|
||||
`android-app-i18n: appKeys=${base.size} locales=${NATIVE_I18N_LOCALES.join(",")}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
#!/usr/bin/env node
|
||||
import { execFileSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { runAndroidSigningCommandSync } from "./lib/android-release-signing-process.mjs";
|
||||
|
||||
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const defaultManifestPath = path.join(rootDir, "apps", "android", "Config", "ReleaseSigning.json");
|
||||
@@ -185,7 +185,7 @@ function requireMatchPassword() {
|
||||
}
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
execFileSync(command, args, {
|
||||
runAndroidSigningCommandSync(command, args, {
|
||||
cwd: options.cwd,
|
||||
env: options.env || process.env,
|
||||
stdio: options.stdio || "pipe",
|
||||
@@ -193,7 +193,7 @@ function run(command, args, options = {}) {
|
||||
}
|
||||
|
||||
function runText(command, args, options = {}) {
|
||||
return execFileSync(command, args, {
|
||||
return runAndroidSigningCommandSync(command, args, {
|
||||
cwd: options.cwd,
|
||||
env: options.env || process.env,
|
||||
encoding: "utf8",
|
||||
|
||||
+127
-22
@@ -4,18 +4,20 @@ set -euo pipefail
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
scripts/android-screenshots.sh [--device <adb-serial>] [--avd <name>] [--locale en-US] [--skip-build] [--skip-install] [--keep-emulator] [--dry-run]
|
||||
scripts/android-screenshots.sh [--form-factor all|phone|wear] [--device <adb-serial>] [--avd <name>] [--locale en-US] [--skip-build] [--skip-install] [--keep-emulator] [--dry-run]
|
||||
|
||||
Builds and installs the Play debug app on an emulator, launches production screens
|
||||
with deterministic local fixture state, and writes Google Play screenshots under:
|
||||
Builds and installs the phone and Wear OS debug apps on matching emulators,
|
||||
launches production screens with deterministic local fixture state, and writes
|
||||
Google Play screenshots under:
|
||||
apps/android/fastlane/metadata/android/<locale>/images/phoneScreenshots/
|
||||
apps/android/fastlane/metadata/android/<locale>/images/wearScreenshots/
|
||||
|
||||
Capture evidence is saved under:
|
||||
.artifacts/android-screenshots/latest/
|
||||
|
||||
By default, the script creates and boots a retained Pixel 2 AVD with no display
|
||||
cutout. Use --avd or ANDROID_SCREENSHOT_AVD to select another AVD, or --device
|
||||
to explicitly use a connected emulator.
|
||||
By default, the script captures both form factors using retained Pixel 2 and
|
||||
Wear OS Large Round AVDs. Use --form-factor with --avd or --device to capture
|
||||
one form factor on an explicitly selected emulator.
|
||||
EOF
|
||||
}
|
||||
|
||||
@@ -23,11 +25,15 @@ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
ANDROID_DIR="${ROOT_DIR}/apps/android"
|
||||
DEFAULT_SCREENSHOT_AVD="OpenClaw_Screenshots_API36"
|
||||
DEFAULT_SCREENSHOT_DEVICE_PROFILE="pixel_2"
|
||||
DEFAULT_WEAR_SCREENSHOT_AVD="OpenClaw_Wear_Screenshots_API34"
|
||||
DEFAULT_WEAR_SCREENSHOT_DEVICE_PROFILE="wearos_large_round"
|
||||
case "$(uname -m)" in
|
||||
arm64|aarch64) DEFAULT_SCREENSHOT_ABI="arm64-v8a" ;;
|
||||
*) DEFAULT_SCREENSHOT_ABI="x86_64" ;;
|
||||
esac
|
||||
DEFAULT_SCREENSHOT_SYSTEM_IMAGE="system-images;android-36;google_apis;${DEFAULT_SCREENSHOT_ABI}"
|
||||
DEFAULT_WEAR_SCREENSHOT_SYSTEM_IMAGE="system-images;android-34;android-wear;${DEFAULT_SCREENSHOT_ABI}"
|
||||
FORM_FACTOR="${ANDROID_SCREENSHOT_FORM_FACTOR:-all}"
|
||||
LOCALE="en-US"
|
||||
DEVICE="${ANDROID_SCREENSHOT_DEVICE:-}"
|
||||
AVD="${ANDROID_SCREENSHOT_AVD:-$DEFAULT_SCREENSHOT_AVD}"
|
||||
@@ -37,11 +43,15 @@ KEEP_EMULATOR="${ANDROID_SCREENSHOT_KEEP_EMULATOR:-0}"
|
||||
SKIP_BUILD=0
|
||||
SKIP_INSTALL=0
|
||||
DRY_RUN=0
|
||||
SCENES=(home chat voice settings gateway voice-wake)
|
||||
SCENES=(home chat settings gateway voice-wake)
|
||||
OUTPUT_TYPE="phoneScreenshots"
|
||||
GRADLE_ASSEMBLE_TASK=":app:assemblePlayDebug"
|
||||
ACTIVITY_COMPONENT="ai.openclaw.app/.MainActivity"
|
||||
EMULATOR_PID=""
|
||||
EMULATOR_LOG=""
|
||||
STARTED_EMULATOR=0
|
||||
ARTIFACT_DIR="${ROOT_DIR}/.artifacts/android-screenshots/latest"
|
||||
ARTIFACT_ROOT="${ROOT_DIR}/.artifacts/android-screenshots/latest"
|
||||
ARTIFACT_DIR="${ARTIFACT_ROOT}/phone"
|
||||
SCREENSHOT_SIZE="${ANDROID_SCREENSHOT_SIZE:-1440x2560}"
|
||||
DISPLAY_OVERRIDDEN=0
|
||||
ORIGINAL_WM_SIZE=""
|
||||
@@ -50,18 +60,26 @@ SCREENSHOT_DENSITY=""
|
||||
TIMEZONE_OVERRIDDEN=0
|
||||
ORIGINAL_AUTO_TIME_ZONE=""
|
||||
ORIGINAL_TIME_ZONE=""
|
||||
DEVICE_EXPLICIT=0
|
||||
AVD_EXPLICIT=0
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--)
|
||||
shift
|
||||
;;
|
||||
--form-factor)
|
||||
FORM_FACTOR="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--device)
|
||||
DEVICE="${2:-}"
|
||||
DEVICE_EXPLICIT=1
|
||||
shift 2
|
||||
;;
|
||||
--avd)
|
||||
AVD="${2:-}"
|
||||
AVD_EXPLICIT=1
|
||||
shift 2
|
||||
;;
|
||||
--locale)
|
||||
@@ -96,6 +114,49 @@ while [[ $# -gt 0 ]]; do
|
||||
esac
|
||||
done
|
||||
|
||||
case "$FORM_FACTOR" in
|
||||
all)
|
||||
if [[ "$DEVICE_EXPLICIT" == "1" || "$AVD_EXPLICIT" == "1" ]]; then
|
||||
echo "--device and --avd require --form-factor phone or --form-factor wear." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$KEEP_EMULATOR" == "1" ]]; then
|
||||
echo "--keep-emulator requires --form-factor phone or --form-factor wear." >&2
|
||||
exit 1
|
||||
fi
|
||||
child_args=(--locale "$LOCALE")
|
||||
[[ "$SKIP_BUILD" == "1" ]] && child_args+=(--skip-build)
|
||||
[[ "$SKIP_INSTALL" == "1" ]] && child_args+=(--skip-install)
|
||||
[[ "$DRY_RUN" == "1" ]] && child_args+=(--dry-run)
|
||||
bash "$0" --form-factor phone "${child_args[@]}"
|
||||
bash "$0" --form-factor wear "${child_args[@]}"
|
||||
exit 0
|
||||
;;
|
||||
phone)
|
||||
;;
|
||||
wear)
|
||||
if [[ "$DEVICE_EXPLICIT" != "1" ]]; then
|
||||
DEVICE="${ANDROID_WEAR_SCREENSHOT_DEVICE:-}"
|
||||
fi
|
||||
if [[ "$AVD_EXPLICIT" != "1" ]]; then
|
||||
AVD="${ANDROID_WEAR_SCREENSHOT_AVD:-$DEFAULT_WEAR_SCREENSHOT_AVD}"
|
||||
fi
|
||||
SCREENSHOT_DEVICE_PROFILE="${ANDROID_WEAR_SCREENSHOT_DEVICE_PROFILE:-$DEFAULT_WEAR_SCREENSHOT_DEVICE_PROFILE}"
|
||||
SCREENSHOT_SYSTEM_IMAGE="${ANDROID_WEAR_SCREENSHOT_SYSTEM_IMAGE:-$DEFAULT_WEAR_SCREENSHOT_SYSTEM_IMAGE}"
|
||||
SCREENSHOT_SIZE="${ANDROID_WEAR_SCREENSHOT_SIZE:-454x454}"
|
||||
SCENES=(chat voice controls)
|
||||
OUTPUT_TYPE="wearScreenshots"
|
||||
GRADLE_ASSEMBLE_TASK=":wear:assembleDebug"
|
||||
ACTIVITY_COMPONENT="ai.openclaw.app/ai.openclaw.wear.MainActivity"
|
||||
ARTIFACT_DIR="${ARTIFACT_ROOT}/wear"
|
||||
;;
|
||||
*)
|
||||
echo "Invalid Android screenshot form factor: ${FORM_FACTOR}" >&2
|
||||
echo "Use all, phone, or wear." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
validate_locale() {
|
||||
local locale="$1"
|
||||
if [[ "$locale" =~ ^[A-Za-z0-9][A-Za-z0-9_-]*$ ]]; then
|
||||
@@ -131,6 +192,13 @@ cleanup_started_emulator() {
|
||||
if [[ -n "${ADB_BIN:-}" && -n "${ADB_SERIAL:-}" ]]; then
|
||||
if "$ADB_BIN" -s "$ADB_SERIAL" emu kill >/dev/null 2>&1; then
|
||||
stopped=1
|
||||
local deadline=$((SECONDS + 30))
|
||||
while (( SECONDS < deadline )); do
|
||||
if ! "$ADB_BIN" devices | awk 'NR > 1 && $2 == "device" { print $1 }' | grep -Fxq "$ADB_SERIAL"; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
fi
|
||||
fi
|
||||
if [[ "$stopped" != "1" && -n "$EMULATOR_PID" ]]; then
|
||||
@@ -224,10 +292,6 @@ avdmanager_bin() {
|
||||
printf '%s\n' "$AVDMANAGER"
|
||||
return
|
||||
fi
|
||||
if command -v avdmanager >/dev/null 2>&1; then
|
||||
command -v avdmanager
|
||||
return
|
||||
fi
|
||||
for sdk_root in "${ANDROID_HOME:-}" "${ANDROID_SDK_ROOT:-}" "$HOME/Library/Android/sdk"; do
|
||||
for relative_path in cmdline-tools/latest/bin/avdmanager cmdline-tools/bin/avdmanager tools/bin/avdmanager; do
|
||||
if [[ -n "$sdk_root" && -x "$sdk_root/$relative_path" ]]; then
|
||||
@@ -236,6 +300,10 @@ avdmanager_bin() {
|
||||
fi
|
||||
done
|
||||
done
|
||||
if command -v avdmanager >/dev/null 2>&1; then
|
||||
command -v avdmanager
|
||||
return
|
||||
fi
|
||||
echo "avdmanager not found. Install Android SDK command-line tools or set AVDMANAGER." >&2
|
||||
return 127
|
||||
}
|
||||
@@ -368,6 +436,10 @@ stabilize_device_for_screenshots() {
|
||||
"$adb" -s "$serial" shell settings put global window_animation_scale 0 >/dev/null 2>&1 || true
|
||||
"$adb" -s "$serial" shell settings put global transition_animation_scale 0 >/dev/null 2>&1 || true
|
||||
"$adb" -s "$serial" shell settings put global animator_duration_scale 0 >/dev/null 2>&1 || true
|
||||
"$adb" -s "$serial" shell settings put global stay_on_while_plugged_in 3 >/dev/null 2>&1 || true
|
||||
"$adb" -s "$serial" shell svc power stayon true >/dev/null 2>&1 || true
|
||||
"$adb" -s "$serial" shell input keyevent 224 >/dev/null 2>&1 || true
|
||||
"$adb" -s "$serial" shell wm dismiss-keyguard >/dev/null 2>&1 || true
|
||||
"$adb" -s "$serial" shell settings put system font_scale 1.0 >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
@@ -471,20 +543,41 @@ resolve_device() {
|
||||
return 1
|
||||
}
|
||||
|
||||
latest_play_debug_apk() {
|
||||
if [[ ! -d "${ANDROID_DIR}/app/build/outputs/apk/play/debug" ]]; then
|
||||
latest_debug_apk() {
|
||||
local output_dir
|
||||
local pattern
|
||||
|
||||
if [[ "$FORM_FACTOR" == "wear" ]]; then
|
||||
output_dir="${ANDROID_DIR}/wear/build/outputs/apk/debug"
|
||||
pattern='*-debug.apk'
|
||||
else
|
||||
output_dir="${ANDROID_DIR}/app/build/outputs/apk/play/debug"
|
||||
pattern='*-play-debug.apk'
|
||||
fi
|
||||
if [[ ! -d "$output_dir" ]]; then
|
||||
return 0
|
||||
fi
|
||||
find "${ANDROID_DIR}/app/build/outputs/apk/play/debug" -maxdepth 1 -name '*-play-debug.apk' -print 2>/dev/null | sort | tail -n 1
|
||||
find "$output_dir" -maxdepth 1 -name "$pattern" -print 2>/dev/null | sort | tail -n 1
|
||||
}
|
||||
|
||||
scene_ready_text() {
|
||||
if [[ "$FORM_FACTOR" == "wear" ]]; then
|
||||
case "$1" in
|
||||
chat) printf '%s\n' "Release planning" ;;
|
||||
voice) printf '%s\n' "Dictate" ;;
|
||||
controls) printf '%s\n' "Gateway connected" ;;
|
||||
*)
|
||||
echo "Unknown Wear OS screenshot scene: $1" >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
return
|
||||
fi
|
||||
case "$1" in
|
||||
home) printf '%s\n' "Overview" ;;
|
||||
# The screenshot fixture seeds chat history and restores at the latest user
|
||||
# turn, so wait for that visible anchor instead of empty-chat copy.
|
||||
chat) printf '%s\n' "Draft a short status update for the team." ;;
|
||||
voice) printf '%s\n' "Ready to talk" ;;
|
||||
settings) printf '%s\n' "OpenClaw mobile" ;;
|
||||
voice-wake) printf '%s\n' "Wake listener" ;;
|
||||
# Connected fixtures can push Add Gateway below the composed viewport, so
|
||||
@@ -509,6 +602,13 @@ wait_for_scene_ready() {
|
||||
marker="$(scene_ready_text "$scene")"
|
||||
while (( SECONDS < deadline )); do
|
||||
if "$adb" -s "$serial" exec-out uiautomator dump /dev/tty >"$dump_path" 2>/dev/null; then
|
||||
# A newly created Wear AVD may cover the resumed app with its charging
|
||||
# overlay after stay-awake is enabled. Dismiss it or first-run capture stalls.
|
||||
if [[ "$FORM_FACTOR" == "wear" ]] && grep -Fq 'com.google.android.wearable.sysui:id/charging_container' "$dump_path"; then
|
||||
"$adb" -s "$serial" shell input keyevent 4 >/dev/null 2>&1 || true
|
||||
sleep 0.5
|
||||
continue
|
||||
fi
|
||||
if grep -Fq "$marker" "$dump_path"; then
|
||||
return
|
||||
fi
|
||||
@@ -566,6 +666,8 @@ write_artifact_manifest() {
|
||||
|
||||
{
|
||||
printf 'git_sha=%s\n' "$git_sha"
|
||||
printf 'form_factor=%s\n' "$FORM_FACTOR"
|
||||
printf 'output_type=%s\n' "$OUTPUT_TYPE"
|
||||
printf 'device=%s\n' "$serial"
|
||||
printf 'avd=%s\n' "${avd_name:-unknown}"
|
||||
printf 'locale=%s\n' "$LOCALE"
|
||||
@@ -578,12 +680,13 @@ write_artifact_manifest() {
|
||||
} >"$ARTIFACT_DIR/manifest.txt"
|
||||
}
|
||||
|
||||
OUTPUT_DIR="${ANDROID_DIR}/fastlane/metadata/android/${LOCALE}/images/phoneScreenshots"
|
||||
OUTPUT_DIR="${ANDROID_DIR}/fastlane/metadata/android/${LOCALE}/images/${OUTPUT_TYPE}"
|
||||
ADB_SERIAL=""
|
||||
ADB_DISPLAY="${DEVICE:-<auto>}"
|
||||
|
||||
echo "Android screenshot output: ${OUTPUT_DIR}"
|
||||
echo "Android screenshot artifacts: ${ARTIFACT_DIR}"
|
||||
echo "Android screenshot form factor: ${FORM_FACTOR}"
|
||||
echo "Android screenshot size: ${SCREENSHOT_SIZE}"
|
||||
echo "Scenes: ${SCENES[*]}"
|
||||
echo "ADB device: ${ADB_DISPLAY}"
|
||||
@@ -610,19 +713,19 @@ if [[ "$SKIP_INSTALL" != "1" ]]; then
|
||||
if [[ "$SKIP_BUILD" != "1" ]]; then
|
||||
(
|
||||
cd "$ANDROID_DIR"
|
||||
./gradlew :app:assemblePlayDebug
|
||||
./gradlew "$GRADLE_ASSEMBLE_TASK"
|
||||
)
|
||||
fi
|
||||
APK_PATH="$(latest_play_debug_apk)"
|
||||
APK_PATH="$(latest_debug_apk)"
|
||||
if [[ -z "$APK_PATH" ]]; then
|
||||
echo "No existing Play debug APK found. Run without --skip-build first." >&2
|
||||
echo "No existing ${FORM_FACTOR} debug APK found. Run without --skip-build first." >&2
|
||||
exit 1
|
||||
fi
|
||||
"$ADB_BIN" -s "$ADB_SERIAL" install -r "$APK_PATH" >/dev/null
|
||||
elif [[ "$SKIP_BUILD" != "1" ]]; then
|
||||
(
|
||||
cd "$ANDROID_DIR"
|
||||
./gradlew :app:assemblePlayDebug
|
||||
./gradlew "$GRADLE_ASSEMBLE_TASK"
|
||||
)
|
||||
fi
|
||||
|
||||
@@ -637,8 +740,10 @@ for scene in "${SCENES[@]}"; do
|
||||
ui_dump_path="${ARTIFACT_DIR}/ui-dumps/openclaw-${scene}.xml"
|
||||
activity_start_path="${ARTIFACT_DIR}/activity-start/openclaw-${scene}.txt"
|
||||
"$ADB_BIN" -s "$ADB_SERIAL" shell am force-stop ai.openclaw.app >/dev/null
|
||||
"$ADB_BIN" -s "$ADB_SERIAL" shell input keyevent 224 >/dev/null 2>&1 || true
|
||||
"$ADB_BIN" -s "$ADB_SERIAL" shell wm dismiss-keyguard >/dev/null 2>&1 || true
|
||||
"$ADB_BIN" -s "$ADB_SERIAL" shell am start -W \
|
||||
-n ai.openclaw.app/.MainActivity \
|
||||
-n "$ACTIVITY_COMPONENT" \
|
||||
--ez openclaw.screenshotMode true \
|
||||
--es openclaw.screenshotScene "$scene" >"$activity_start_path"
|
||||
wait_for_scene_ready "$ADB_BIN" "$ADB_SERIAL" "$scene" "$ui_dump_path"
|
||||
|
||||
@@ -14,6 +14,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
|
||||
import type { AuthProfileCredential } from "../src/agents/auth-profiles.js";
|
||||
import {
|
||||
parseBooleanEnv,
|
||||
@@ -140,7 +141,7 @@ function summarizeText(text: string, max = 120): string {
|
||||
if (normalized.length <= max) {
|
||||
return normalized;
|
||||
}
|
||||
return `${normalized.slice(0, max - 1)}…`;
|
||||
return `${truncateUtf16Safe(normalized, max - 1)}…`;
|
||||
}
|
||||
|
||||
function summarizeCapture(
|
||||
|
||||
+195
-84
@@ -37,15 +37,20 @@ const INFLECTED_COUNT_SEGMENT_RE =
|
||||
/\^\[[^\]]*\\\([A-Za-z_][A-Za-z0-9_]*\)[^\]]*\]\(inflect: true\)/gu;
|
||||
const INFLECTED_COUNT_MARKER = "](inflect: true)";
|
||||
const IOS_CATALOG_PATH = "apps/ios/Resources/Localizable.xcstrings";
|
||||
const MACOS_CATALOG_PATH = "apps/macos/Sources/OpenClaw/Resources/Localizable.xcstrings";
|
||||
const MACOS_INFO_PLIST_PATH = "apps/macos/Sources/OpenClaw/Resources/Info.plist";
|
||||
const IOS_CONTRADICTIONS_PATH = "apps/.i18n/apple-translation-contradictions.json";
|
||||
const NATIVE_SOURCE_PATH = "apps/.i18n/native-source.json";
|
||||
const NATIVE_TRANSLATIONS_DIR = "apps/.i18n/native";
|
||||
const SHARED_CHAT_UI_SOURCE_PREFIX = "apps/shared/OpenClawKit/Sources/OpenClawChatUI/";
|
||||
const SHARED_GATEWAY_DISCOVERY_STATUS_SOURCE =
|
||||
"apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayDiscoveryStatusText.swift";
|
||||
const IOS_SOURCE_PREFIXES = [
|
||||
"apps/ios/",
|
||||
"apps/shared/OpenClawKit/Sources/OpenClawChatUI/",
|
||||
SHARED_CHAT_UI_SOURCE_PREFIX,
|
||||
"apps/shared/OpenClawKit/Sources/OpenClawKit/",
|
||||
] as const;
|
||||
const IOS_CATALOG_KINDS = new Set([
|
||||
const APPLE_CATALOG_KINDS = new Set([
|
||||
"conditional-branch",
|
||||
"ui-call",
|
||||
"ui-call-multiline",
|
||||
@@ -60,6 +65,15 @@ const IOS_CATALOG_EXCLUSIONS = new Set([
|
||||
"OpenClaw",
|
||||
"z",
|
||||
]);
|
||||
const MACOS_SOURCE_PREFIXES = [
|
||||
"apps/macos/Sources/OpenClaw/",
|
||||
SHARED_CHAT_UI_SOURCE_PREFIX,
|
||||
SHARED_GATEWAY_DISCOVERY_STATUS_SOURCE,
|
||||
] as const;
|
||||
const MACOS_CATALOG_EXCLUSIONS = new Set([
|
||||
// Product names are intentionally verbatim.
|
||||
"OpenClaw",
|
||||
]);
|
||||
const IOS_INFO_PLIST_TARGETS = [
|
||||
{
|
||||
outputRoot: "apps/ios/Sources",
|
||||
@@ -78,6 +92,7 @@ const IOS_INFO_PLIST_TARGETS = [
|
||||
sourcePath: "apps/ios/ActivityWidget/Info.plist",
|
||||
},
|
||||
] as const;
|
||||
const INFO_PLIST_LOCALIZABLE_KEYS = new Set(["NSScreenCaptureDescription"]);
|
||||
const AMBIGUOUS_RUNTIME_INTERPOLATIONS = [
|
||||
{
|
||||
label: "interpolated localized resource",
|
||||
@@ -143,6 +158,18 @@ const APPLE_LOCALE_DIRECTORIES: Record<string, string> = {
|
||||
"zh-TW": "zh-Hant",
|
||||
};
|
||||
const LOCALIZED_WRAPPER_CONTRACTS: Record<string, readonly string[]> = {
|
||||
"apps/macos/Sources/OpenClaw/SettingsComponents.swift": [
|
||||
"enum SettingsTextValue: ExpressibleByStringLiteral",
|
||||
"case localized(LocalizedStringKey)",
|
||||
"case verbatim(String)",
|
||||
"static func localized(_ value: String) -> Self",
|
||||
"struct SettingsPageHeader: View {\n let title: SettingsTextValue\n let subtitle: SettingsTextValue?",
|
||||
"struct SettingsCardGroup<Content: View>: View {\n let title: SettingsTextValue",
|
||||
"struct SettingsCardRow<Content: View>: View {\n let title: SettingsTextValue\n let subtitle: SettingsTextValue?",
|
||||
"struct SettingsCardToggleRow: View {\n let title: SettingsTextValue\n let subtitle: SettingsTextValue?",
|
||||
"struct SettingsToggleRow: View {\n let title: SettingsTextValue\n let subtitle: SettingsTextValue?",
|
||||
"Text(verbatim: value)",
|
||||
],
|
||||
"apps/ios/Sources/Design/OpenClawProComponents.swift": [
|
||||
"enum OpenClawTextValue: ExpressibleByStringLiteral",
|
||||
"struct ProSectionHeader: View {\n let title: OpenClawTextValue",
|
||||
@@ -218,6 +245,18 @@ const LOCALIZED_WRAPPER_CONTRACTS: Record<string, readonly string[]> = {
|
||||
"private func detailMetric(label: OpenClawTextValue, value: String)",
|
||||
"title: OpenClawTextValue,\n detail: OpenClawTextValue",
|
||||
],
|
||||
"apps/ios/Sources/Design/AgentProDreamingDestination.swift": [
|
||||
"private func detailMetric(label: OpenClawTextValue, value: String)",
|
||||
"label.text",
|
||||
"Text(verbatim: value)",
|
||||
],
|
||||
"apps/ios/Sources/Design/AgentProTab+DetailComponents.swift": [
|
||||
"func detailMetric(label: OpenClawTextValue, value: String)",
|
||||
"Text(verbatim: value)",
|
||||
"func emptyDetailRow(\n icon: String,\n title: OpenClawTextValue,\n detail: OpenClawTextValue)",
|
||||
"title.text",
|
||||
"detail.text",
|
||||
],
|
||||
"apps/ios/Sources/Design/CommandCenterSupport.swift": [
|
||||
"Text(verbatim: self.item.title)",
|
||||
"Text(verbatim: self.item.trailing)",
|
||||
@@ -283,6 +322,12 @@ const LOCALIZED_WRAPPER_CONTRACTS: Record<string, readonly string[]> = {
|
||||
],
|
||||
};
|
||||
const RAW_LOCALIZATION_BYPASSES: Record<string, readonly string[]> = {
|
||||
"apps/macos/Sources/OpenClaw/SettingsComponents.swift": [
|
||||
"let title: String",
|
||||
"let subtitle: String?",
|
||||
"Text(self.title)",
|
||||
"Text(subtitle)",
|
||||
],
|
||||
"apps/ios/Sources/Design/SettingsProTabSections.swift": [
|
||||
"func settingsListRow(\n icon: String,\n iconColor: Color,\n title: String",
|
||||
"func aboutLinkRow(title: String",
|
||||
@@ -372,19 +417,6 @@ const RAW_LOCALIZATION_BYPASSES: Record<string, readonly string[]> = {
|
||||
],
|
||||
};
|
||||
|
||||
const MACOS_CATALOG = {
|
||||
path: "apps/macos/Sources/OpenClaw/Resources/Localizable.xcstrings",
|
||||
coverage: {
|
||||
"apps/macos/Sources/OpenClaw/ChannelsSettings+ChannelSections.swift": [
|
||||
"Logout",
|
||||
"Refresh",
|
||||
"Save",
|
||||
],
|
||||
"apps/macos/Sources/OpenClaw/CronSettings+Rows.swift": ["Run now"],
|
||||
"apps/macos/Sources/OpenClaw/OnboardingSystemAgentChat.swift": ["Wake up, my friend!"],
|
||||
},
|
||||
} as const;
|
||||
|
||||
type StringUnit = {
|
||||
state?: string;
|
||||
value?: string;
|
||||
@@ -467,7 +499,8 @@ function parseInfoPlistStrings(source: string): Array<{ key: string; source: str
|
||||
source: decodeXml(match[2] ?? ""),
|
||||
}))
|
||||
.filter(
|
||||
(entry) => entry.key === "CFBundleDisplayName" || entry.key.endsWith("UsageDescription"),
|
||||
(entry) =>
|
||||
entry.key.endsWith("UsageDescription") || INFO_PLIST_LOCALIZABLE_KEYS.has(entry.key),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -515,6 +548,36 @@ export function infoPlistTranslationCandidates(
|
||||
);
|
||||
}
|
||||
|
||||
function infoPlistSourceIds(nativeSource: NativeSourceArtifact): Map<string, string> {
|
||||
return new Map(
|
||||
nativeSource.entries
|
||||
.filter((entry) => entry.kind === "plist-string")
|
||||
.map((entry) => [[entry.path, entry.source].join("\u0000"), entry.id]),
|
||||
);
|
||||
}
|
||||
|
||||
function renderInfoPlistStrings(
|
||||
sourcePath: string,
|
||||
sourceEntries: ReadonlyArray<{ key: string; source: string }>,
|
||||
sourceIds: ReadonlyMap<string, string>,
|
||||
artifact: NativeTranslationArtifact | undefined,
|
||||
existing: ReadonlyMap<string, InfoPlistTranslation> = new Map(),
|
||||
): string {
|
||||
const lines = sourceEntries.map(({ key, source }) => {
|
||||
const sourceId = sourceIds.get([sourcePath, source].join("\u0000"));
|
||||
if (!sourceId) {
|
||||
throw new Error(`missing native InfoPlist source id for ${sourcePath}:${key}`);
|
||||
}
|
||||
const candidates = infoPlistTranslationCandidates(artifact, sourceId, source);
|
||||
const value = selectInfoPlistTranslation(source, candidates, existing.get(key));
|
||||
return [
|
||||
`/* OpenClaw source: ${stringsLiteral(source)} */`,
|
||||
`${stringsLiteral(key)} = ${stringsLiteral(value)};`,
|
||||
].join("\n");
|
||||
});
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
async function readOptionalFile(filePath: string): Promise<string | null> {
|
||||
try {
|
||||
return await readFile(filePath, "utf8");
|
||||
@@ -530,12 +593,22 @@ function isIosCatalogEntry(entry: NativeSourceEntry): boolean {
|
||||
return (
|
||||
entry.surface === "apple" &&
|
||||
IOS_SOURCE_PREFIXES.some((prefix) => entry.path.startsWith(prefix)) &&
|
||||
IOS_CATALOG_KINDS.has(entry.kind) &&
|
||||
APPLE_CATALOG_KINDS.has(entry.kind) &&
|
||||
(!entry.source.includes("\\(") || isInflectedCountSource(entry.source)) &&
|
||||
!IOS_CATALOG_EXCLUSIONS.has(entry.source)
|
||||
);
|
||||
}
|
||||
|
||||
function isMacosCatalogEntry(entry: NativeSourceEntry): boolean {
|
||||
return (
|
||||
entry.surface === "apple" &&
|
||||
MACOS_SOURCE_PREFIXES.some((prefix) => entry.path.startsWith(prefix)) &&
|
||||
APPLE_CATALOG_KINDS.has(entry.kind) &&
|
||||
!entry.source.includes("\\(") &&
|
||||
!MACOS_CATALOG_EXCLUSIONS.has(entry.source)
|
||||
);
|
||||
}
|
||||
|
||||
function isInflectedCountSource(value: string): boolean {
|
||||
if (!value.includes(INFLECTED_COUNT_MARKER)) {
|
||||
return false;
|
||||
@@ -578,18 +651,19 @@ function chooseTranslation(source: string, translations: readonly string[]): str
|
||||
);
|
||||
}
|
||||
|
||||
export function buildIosCatalog(
|
||||
function buildAppleCatalog(
|
||||
existingCatalog: Catalog,
|
||||
nativeSource: NativeSourceArtifact,
|
||||
translations: readonly NativeTranslationArtifact[],
|
||||
includesEntry: (entry: NativeSourceEntry) => boolean,
|
||||
): AppleCatalogBuild {
|
||||
const iosEntries = nativeSource.entries.filter(isIosCatalogEntry);
|
||||
const catalogEntries = iosEntries.map(
|
||||
(entry) => [entry, appleCatalogValue(entry.source)] as const,
|
||||
);
|
||||
const catalogEntries = nativeSource.entries
|
||||
.filter(includesEntry)
|
||||
.map((entry) => [entry, appleCatalogValue(entry.source)] as const);
|
||||
const sources = [...new Set(catalogEntries.map(([, source]) => source))].toSorted(
|
||||
compareCodeUnits,
|
||||
);
|
||||
const sourceSet = new Set(sources);
|
||||
const appleIdsBySource = new Map<string, Set<string>>();
|
||||
for (const [entry, source] of catalogEntries) {
|
||||
const ids = appleIdsBySource.get(source) ?? new Set<string>();
|
||||
@@ -602,7 +676,7 @@ export function buildIosCatalog(
|
||||
const bySource = new Map<string, string[]>();
|
||||
for (const entry of artifact.entries) {
|
||||
const source = appleCatalogValue(entry.source);
|
||||
if (!sources.includes(source)) {
|
||||
if (!sourceSet.has(source)) {
|
||||
continue;
|
||||
}
|
||||
const appleIds = appleIdsBySource.get(source);
|
||||
@@ -666,6 +740,22 @@ export function buildIosCatalog(
|
||||
};
|
||||
}
|
||||
|
||||
export function buildIosCatalog(
|
||||
existingCatalog: Catalog,
|
||||
nativeSource: NativeSourceArtifact,
|
||||
translations: readonly NativeTranslationArtifact[],
|
||||
): AppleCatalogBuild {
|
||||
return buildAppleCatalog(existingCatalog, nativeSource, translations, isIosCatalogEntry);
|
||||
}
|
||||
|
||||
export function buildMacosCatalog(
|
||||
existingCatalog: Catalog,
|
||||
nativeSource: NativeSourceArtifact,
|
||||
translations: readonly NativeTranslationArtifact[],
|
||||
): AppleCatalogBuild {
|
||||
return buildAppleCatalog(existingCatalog, nativeSource, translations, isMacosCatalogEntry);
|
||||
}
|
||||
|
||||
async function listSwiftFiles(directory: string): Promise<string[]> {
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
const nested = await Promise.all(
|
||||
@@ -744,6 +834,17 @@ async function readIosCatalogBuild(): Promise<AppleCatalogBuild> {
|
||||
return buildIosCatalog(existingCatalog, nativeSource, translations);
|
||||
}
|
||||
|
||||
async function readMacosCatalogBuild(): Promise<AppleCatalogBuild> {
|
||||
const existingCatalog = JSON.parse(
|
||||
await readFile(path.join(ROOT, MACOS_CATALOG_PATH), "utf8"),
|
||||
) as Catalog;
|
||||
const nativeSource = JSON.parse(
|
||||
await readFile(path.join(ROOT, NATIVE_SOURCE_PATH), "utf8"),
|
||||
) as NativeSourceArtifact;
|
||||
const translations = await readNativeTranslations();
|
||||
return buildMacosCatalog(existingCatalog, nativeSource, translations);
|
||||
}
|
||||
|
||||
function validateCatalog(pathName: string, catalog: Catalog): number {
|
||||
if (catalog.sourceLanguage !== "en" || catalog.version !== "1.0" || !catalog.strings) {
|
||||
throw new Error(`invalid Apple string catalog: ${pathName}`);
|
||||
@@ -780,11 +881,7 @@ async function syncIosInfoPlist(write: boolean): Promise<number> {
|
||||
const nativeSource = JSON.parse(
|
||||
await readFile(path.join(ROOT, NATIVE_SOURCE_PATH), "utf8"),
|
||||
) as NativeSourceArtifact;
|
||||
const sourceIds = new Map(
|
||||
nativeSource.entries
|
||||
.filter((entry) => entry.kind === "plist-string")
|
||||
.map((entry) => [[entry.path, entry.source].join("\u0000"), entry.id]),
|
||||
);
|
||||
const sourceIds = infoPlistSourceIds(nativeSource);
|
||||
let checked = 0;
|
||||
for (const target of IOS_INFO_PLIST_TARGETS) {
|
||||
const sourceEntries = parseInfoPlistStrings(
|
||||
@@ -801,22 +898,13 @@ async function syncIosInfoPlist(write: boolean): Promise<number> {
|
||||
const existingSource = await readOptionalFile(outputPath);
|
||||
const existing = parseStringsFile(existingSource ?? "");
|
||||
const artifact = translations.find((candidate) => candidate.locale === locale);
|
||||
const lines = sourceEntries.map(({ key, source }) => {
|
||||
if (key === "CFBundleDisplayName") {
|
||||
return `${stringsLiteral(key)} = ${stringsLiteral(source)};`;
|
||||
}
|
||||
const sourceId = sourceIds.get([target.sourcePath, source].join("\u0000"));
|
||||
if (!sourceId) {
|
||||
throw new Error(`missing native InfoPlist source id for ${target.sourcePath}:${key}`);
|
||||
}
|
||||
const candidates = infoPlistTranslationCandidates(artifact, sourceId, source);
|
||||
const value = selectInfoPlistTranslation(source, candidates, existing.get(key));
|
||||
return [
|
||||
`/* OpenClaw source: ${stringsLiteral(source)} */`,
|
||||
`${stringsLiteral(key)} = ${stringsLiteral(value)};`,
|
||||
].join("\n");
|
||||
});
|
||||
const expected = `${lines.join("\n")}\n`;
|
||||
const expected = renderInfoPlistStrings(
|
||||
target.sourcePath,
|
||||
sourceEntries,
|
||||
sourceIds,
|
||||
artifact,
|
||||
existing,
|
||||
);
|
||||
if (existingSource !== expected) {
|
||||
if (!write) {
|
||||
throw new Error(
|
||||
@@ -859,18 +947,42 @@ export async function syncIosCatalog(write: boolean): Promise<AppleCatalogBuild>
|
||||
return build;
|
||||
}
|
||||
|
||||
export async function syncMacosCatalog(write: boolean): Promise<AppleCatalogBuild> {
|
||||
const build = await readMacosCatalogBuild();
|
||||
const catalogPath = path.join(ROOT, MACOS_CATALOG_PATH);
|
||||
const expected = serializeCatalog(build.catalog);
|
||||
const actual = await readFile(catalogPath, "utf8");
|
||||
if (actual !== expected) {
|
||||
if (!write) {
|
||||
assertMacosCatalogCurrent(actual, build);
|
||||
return build;
|
||||
}
|
||||
await writeFile(catalogPath, expected, "utf8");
|
||||
}
|
||||
return build;
|
||||
}
|
||||
|
||||
export function assertMacosCatalogCurrent(actual: string, build: AppleCatalogBuild): void {
|
||||
if (actual !== serializeCatalog(build.catalog)) {
|
||||
throw new Error(
|
||||
`Apple catalog ${MACOS_CATALOG_PATH} is stale; run native-app-i18n.ts sync --write`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Regenerates every Apple derived artifact (iOS catalog, contradiction report,
|
||||
* Regenerates every Apple derived artifact (app catalogs, contradiction report,
|
||||
* InfoPlist strings). Shared by this CLI and native-app-i18n's sync so the
|
||||
* inventory can never be rewritten without its derived catalogs.
|
||||
*/
|
||||
export async function syncAppleAppI18n(): Promise<{
|
||||
build: AppleCatalogBuild;
|
||||
infoPlistFiles: number;
|
||||
macosBuild: AppleCatalogBuild;
|
||||
}> {
|
||||
const build = await syncIosCatalog(true);
|
||||
const [build, macosBuild] = await Promise.all([syncIosCatalog(true), syncMacosCatalog(true)]);
|
||||
const infoPlistFiles = await syncIosInfoPlist(true);
|
||||
return { build, infoPlistFiles };
|
||||
return { build, infoPlistFiles, macosBuild };
|
||||
}
|
||||
|
||||
export async function verifyAppleAppI18n() {
|
||||
@@ -894,48 +1006,29 @@ export async function verifyAppleAppI18n() {
|
||||
}
|
||||
}
|
||||
|
||||
const macosCatalog = JSON.parse(
|
||||
await readFile(path.join(ROOT, MACOS_CATALOG.path), "utf8"),
|
||||
) as Catalog;
|
||||
if (!macosCatalog.strings) {
|
||||
throw new Error(`invalid Apple string catalog: ${MACOS_CATALOG.path}`);
|
||||
}
|
||||
const expectedMacosKeys: Set<string> = new Set(Object.values(MACOS_CATALOG.coverage).flat());
|
||||
const actualMacosKeys = new Set(Object.keys(macosCatalog.strings));
|
||||
const missingMacosKeys = [...expectedMacosKeys].filter((key) => !actualMacosKeys.has(key));
|
||||
const extraMacosKeys = [...actualMacosKeys].filter((key) => !expectedMacosKeys.has(key));
|
||||
if (missingMacosKeys.length || extraMacosKeys.length) {
|
||||
throw new Error(
|
||||
[
|
||||
`Apple catalog ${MACOS_CATALOG.path} does not match its phased source coverage.`,
|
||||
`missing=${missingMacosKeys.join(",") || "none"}`,
|
||||
`extra=${extraMacosKeys.join(",") || "none"}`,
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
for (const [sourcePath, keys] of Object.entries(MACOS_CATALOG.coverage)) {
|
||||
const source = await readFile(path.join(ROOT, sourcePath), "utf8");
|
||||
const absent = keys.filter((key) => !source.includes(key));
|
||||
if (absent.length) {
|
||||
throw new Error(`Apple i18n coverage ${sourcePath} no longer contains: ${absent.join(", ")}`);
|
||||
}
|
||||
}
|
||||
const macosKeys = validateCatalog(MACOS_CATALOG.path, macosCatalog);
|
||||
const macosBuild = await readMacosCatalogBuild();
|
||||
const macosKeys = validateCatalog(MACOS_CATALOG_PATH, macosBuild.catalog);
|
||||
|
||||
process.stdout.write(`apple-app-i18n: sourceMacosKeys=${macosKeys}\n`);
|
||||
}
|
||||
|
||||
export async function checkAppleAppI18n() {
|
||||
await verifyAppleAppI18n();
|
||||
const iosBuild = await syncIosCatalog(false);
|
||||
const [iosBuild, macosBuild] = await Promise.all([
|
||||
syncIosCatalog(false),
|
||||
syncMacosCatalog(false),
|
||||
]);
|
||||
const iosKeys = validateCatalog(IOS_CATALOG_PATH, iosBuild.catalog);
|
||||
const macosKeys = validateCatalog(MACOS_CATALOG_PATH, macosBuild.catalog);
|
||||
const infoPlistFiles = await syncIosInfoPlist(false);
|
||||
|
||||
process.stdout.write(
|
||||
[
|
||||
`apple-app-i18n: iosKeys=${iosKeys}`,
|
||||
`macosKeys=${macosKeys}`,
|
||||
`infoPlistFiles=${infoPlistFiles}`,
|
||||
`translationContradictions=${iosBuild.contradictions.length}`,
|
||||
`macosTranslationContradictions=${macosBuild.contradictions.length}`,
|
||||
`locales=${APPLE_I18N_LOCALES.join(",")}`,
|
||||
"\n",
|
||||
].join(" "),
|
||||
@@ -943,15 +1036,23 @@ export async function checkAppleAppI18n() {
|
||||
}
|
||||
|
||||
export async function compileMacosLocalizations(outputDir: string) {
|
||||
// Source PRs intentionally leave generated iOS catalogs for the serialized
|
||||
// post-merge refresh. Packaging only needs the source-owned macOS contract.
|
||||
// Source PRs intentionally leave generated Apple catalogs for the serialized
|
||||
// post-merge refresh. Package from the derived catalog so source changes
|
||||
// cannot ship stale localization coverage before that refresh lands.
|
||||
await verifyAppleAppI18n();
|
||||
const catalog = JSON.parse(
|
||||
await readFile(path.join(ROOT, MACOS_CATALOG.path), "utf8"),
|
||||
) as Catalog;
|
||||
const catalog = (await readMacosCatalogBuild()).catalog;
|
||||
if (!catalog.strings) {
|
||||
throw new Error(`invalid Apple string catalog: ${MACOS_CATALOG.path}`);
|
||||
throw new Error(`invalid Apple string catalog: ${MACOS_CATALOG_PATH}`);
|
||||
}
|
||||
const [nativeSource, translations, infoPlistSource] = await Promise.all([
|
||||
readFile(path.join(ROOT, NATIVE_SOURCE_PATH), "utf8").then(
|
||||
(source) => JSON.parse(source) as NativeSourceArtifact,
|
||||
),
|
||||
readNativeTranslations(),
|
||||
readFile(path.join(ROOT, MACOS_INFO_PLIST_PATH), "utf8"),
|
||||
]);
|
||||
const sourceIds = infoPlistSourceIds(nativeSource);
|
||||
const infoPlistEntries = parseInfoPlistStrings(infoPlistSource);
|
||||
|
||||
for (const locale of REQUIRED_LOCALES) {
|
||||
const localeDir = APPLE_LOCALE_DIRECTORIES[locale] ?? locale;
|
||||
@@ -962,13 +1063,23 @@ export async function compileMacosLocalizations(outputDir: string) {
|
||||
const value = entry.localizations?.[locale]?.stringUnit?.value;
|
||||
if (!value) {
|
||||
throw new Error(
|
||||
`Apple catalog ${MACOS_CATALOG.path} is missing ${locale} for ${JSON.stringify(key)}`,
|
||||
`Apple catalog ${MACOS_CATALOG_PATH} is missing ${locale} for ${JSON.stringify(key)}`,
|
||||
);
|
||||
}
|
||||
return `${stringsLiteral(key)} = ${stringsLiteral(value)};`;
|
||||
});
|
||||
await mkdir(lprojDir, { recursive: true });
|
||||
await writeFile(path.join(lprojDir, "Localizable.strings"), `${lines.join("\n")}\n`, "utf8");
|
||||
if (locale !== "en") {
|
||||
const artifact = translations.find((candidate) => candidate.locale === locale);
|
||||
const infoPlistStrings = renderInfoPlistStrings(
|
||||
MACOS_INFO_PLIST_PATH,
|
||||
infoPlistEntries,
|
||||
sourceIds,
|
||||
artifact,
|
||||
);
|
||||
await writeFile(path.join(lprojDir, "InfoPlist.strings"), infoPlistStrings, "utf8");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -977,9 +1088,9 @@ if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.ar
|
||||
if (command === "check") {
|
||||
await checkAppleAppI18n();
|
||||
} else if (command === "sync-ios" && flag === "--write") {
|
||||
const { build, infoPlistFiles } = await syncAppleAppI18n();
|
||||
const { build, infoPlistFiles, macosBuild } = await syncAppleAppI18n();
|
||||
process.stdout.write(
|
||||
`apple-app-i18n: synced iOS catalog and ${infoPlistFiles} InfoPlist files; contradictions=${build.contradictions.length}\n`,
|
||||
`apple-app-i18n: synced Apple catalogs and ${infoPlistFiles} InfoPlist files; contradictions=${build.contradictions.length + macosBuild.contradictions.length}\n`,
|
||||
);
|
||||
} else if (command === "compile-macos" && flag === "--output" && value) {
|
||||
await compileMacosLocalizations(path.resolve(value));
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
// Benchmarks the real Codex transcript mirror against a large indexed SQLite transcript.
|
||||
import { execFileSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { SQLInputValue } from "node:sqlite";
|
||||
import { codexTranscriptMirrorRuntime } from "../extensions/codex/src/app-server/transcript-mirror.js";
|
||||
import { attachCodexMirrorIdentity } from "../extensions/codex/src/app-server/upstream-prompt-provenance.js";
|
||||
import { upsertSessionEntry } from "../src/config/sessions/session-accessor.js";
|
||||
import type { AgentMessage } from "../src/plugin-sdk/agent-core.js";
|
||||
import {
|
||||
closeOpenClawAgentDatabasesForTest,
|
||||
openOpenClawAgentDatabase,
|
||||
} from "../src/state/openclaw-agent-db.js";
|
||||
import { closeOpenClawStateDatabaseForTest } from "../src/state/openclaw-state-db.js";
|
||||
|
||||
const DEFAULT_EVENT_COUNT = 100_000;
|
||||
const DEFAULT_PAYLOAD_BYTES = 64;
|
||||
const DEFAULT_RUNS = 8;
|
||||
const DEFAULT_WARMUPS = 2;
|
||||
const NEW_MESSAGES_PER_OPERATION = 2;
|
||||
|
||||
type MirrorTarget = {
|
||||
agentId: string;
|
||||
sessionId: string;
|
||||
sessionKey: string;
|
||||
storePath: string;
|
||||
};
|
||||
|
||||
type WorkCounters = {
|
||||
fullTranscriptQueries: number;
|
||||
seededEventJsonParses: number;
|
||||
selectQueries: number;
|
||||
};
|
||||
|
||||
function readIntegerArg(name: string, fallback: number): number {
|
||||
const raw = process.argv.find((arg) => arg.startsWith(`--${name}=`))?.slice(name.length + 3);
|
||||
if (raw === undefined) {
|
||||
return fallback;
|
||||
}
|
||||
const value = Number(raw);
|
||||
if (!Number.isSafeInteger(value) || value < 1) {
|
||||
throw new Error(`--${name} must be a positive integer`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function readSourceSha(): string {
|
||||
const value = process.argv
|
||||
.find((arg) => arg.startsWith("--source-sha="))
|
||||
?.slice("--source-sha=".length);
|
||||
if (!value || !/^[a-f0-9]{40}$/u.test(value)) {
|
||||
throw new Error("benchmark requires --source-sha=<40-character commit SHA>");
|
||||
}
|
||||
const checkoutSha = execFileSync("git", ["rev-parse", "HEAD"], {
|
||||
cwd: path.resolve(import.meta.dirname, ".."),
|
||||
encoding: "utf8",
|
||||
}).trim();
|
||||
if (checkoutSha !== value) {
|
||||
throw new Error(`source SHA ${value} does not match checkout HEAD ${checkoutSha}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function median(values: readonly number[]): number {
|
||||
const sorted = values.toSorted((left, right) => left - right);
|
||||
const upperIndex = Math.floor(sorted.length / 2);
|
||||
const upper = sorted[upperIndex] ?? 0;
|
||||
const lower = sorted.length % 2 === 0 ? (sorted[upperIndex - 1] ?? upper) : upper;
|
||||
return Number(((lower + upper) / 2).toFixed(3));
|
||||
}
|
||||
|
||||
function percentile(values: readonly number[], fraction: number): number {
|
||||
const sorted = values.toSorted((left, right) => left - right);
|
||||
const index = Math.min(sorted.length - 1, Math.ceil(sorted.length * fraction) - 1);
|
||||
return Number((sorted[Math.max(0, index)] ?? 0).toFixed(3));
|
||||
}
|
||||
|
||||
/** Seeds a fully indexed linear transcript without charging setup to measured owner calls. */
|
||||
function seedTranscript(params: {
|
||||
database: ReturnType<typeof openOpenClawAgentDatabase>;
|
||||
eventCount: number;
|
||||
payloadText: string;
|
||||
sessionId: string;
|
||||
}): void {
|
||||
const { database, eventCount, payloadText, sessionId } = params;
|
||||
const insertEvent = database.db.prepare(
|
||||
`INSERT INTO transcript_events (session_id, seq, event_json, created_at)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
);
|
||||
const insertIdentity = database.db.prepare(
|
||||
`INSERT INTO transcript_event_identities (
|
||||
session_id, event_id, seq, event_type, parent_id, message_idempotency_key, created_at
|
||||
) VALUES (?, ?, ?, 'message', ?, ?, ?)`,
|
||||
);
|
||||
const insertActive = database.db.prepare(
|
||||
`INSERT INTO session_transcript_active_events (
|
||||
session_id, active_position, event_seq, message_position
|
||||
) VALUES (?, ?, ?, ?)`,
|
||||
);
|
||||
const now = Date.now();
|
||||
database.db.exec("BEGIN IMMEDIATE");
|
||||
try {
|
||||
for (let seq = 0; seq < eventCount; seq += 1) {
|
||||
const eventId = `benchmark-event-${seq}`;
|
||||
const parentId = seq === 0 ? null : `benchmark-event-${seq - 1}`;
|
||||
const idempotencyKey = `seed:${sessionId}:${seq}`;
|
||||
const role = seq % 2 === 0 ? "user" : "assistant";
|
||||
const event = {
|
||||
id: eventId,
|
||||
message: {
|
||||
content: role === "user" ? payloadText : [{ type: "text", text: payloadText }],
|
||||
idempotencyKey,
|
||||
role,
|
||||
timestamp: now + seq,
|
||||
},
|
||||
parentId,
|
||||
timestamp: now + seq,
|
||||
type: "message",
|
||||
};
|
||||
insertEvent.run(sessionId, seq, JSON.stringify(event), now + seq);
|
||||
const identityValues = [
|
||||
sessionId,
|
||||
eventId,
|
||||
seq,
|
||||
parentId,
|
||||
idempotencyKey,
|
||||
now + seq,
|
||||
] satisfies SQLInputValue[];
|
||||
insertIdentity.run(...identityValues);
|
||||
insertActive.run(sessionId, seq, seq, seq);
|
||||
}
|
||||
database.db
|
||||
.prepare(
|
||||
`INSERT INTO session_transcript_index_state (
|
||||
session_id, indexed_seq, leaf_event_id, needs_rebuild,
|
||||
active_event_count, active_message_count, updated_at
|
||||
) VALUES (?, ?, ?, 0, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
sessionId,
|
||||
eventCount - 1,
|
||||
`benchmark-event-${eventCount - 1}`,
|
||||
eventCount,
|
||||
eventCount,
|
||||
now + eventCount,
|
||||
);
|
||||
database.db.exec("COMMIT");
|
||||
} catch (error) {
|
||||
database.db.exec("ROLLBACK");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function instrumentWork(database: ReturnType<typeof openOpenClawAgentDatabase>): {
|
||||
counters: WorkCounters;
|
||||
reset: () => void;
|
||||
restore: () => void;
|
||||
} {
|
||||
const counters: WorkCounters = {
|
||||
fullTranscriptQueries: 0,
|
||||
seededEventJsonParses: 0,
|
||||
selectQueries: 0,
|
||||
};
|
||||
const originalPrepare = database.db.prepare.bind(database.db);
|
||||
const originalParse = JSON.parse;
|
||||
Object.defineProperty(database.db, "prepare", {
|
||||
configurable: true,
|
||||
value: (sql: string) => {
|
||||
const normalized = sql.replaceAll(/\s+/gu, " ").trim().toLowerCase();
|
||||
if (normalized.startsWith("select ")) {
|
||||
counters.selectQueries += 1;
|
||||
}
|
||||
if (
|
||||
/from "?transcript_events"?/u.test(normalized) &&
|
||||
normalized.includes("event_json") &&
|
||||
/order by "?seq"? asc/u.test(normalized)
|
||||
) {
|
||||
counters.fullTranscriptQueries += 1;
|
||||
}
|
||||
return originalPrepare(sql);
|
||||
},
|
||||
});
|
||||
JSON.parse = ((text: string, reviver?: Parameters<typeof JSON.parse>[1]) => {
|
||||
if (text.includes('"id":"benchmark-event-')) {
|
||||
counters.seededEventJsonParses += 1;
|
||||
}
|
||||
return originalParse(text, reviver);
|
||||
}) as typeof JSON.parse;
|
||||
return {
|
||||
counters,
|
||||
reset: () => {
|
||||
counters.fullTranscriptQueries = 0;
|
||||
counters.seededEventJsonParses = 0;
|
||||
counters.selectQueries = 0;
|
||||
},
|
||||
restore: () => {
|
||||
Object.defineProperty(database.db, "prepare", {
|
||||
configurable: true,
|
||||
value: originalPrepare,
|
||||
});
|
||||
JSON.parse = originalParse;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildPromptFinalBatch(ordinal: number): AgentMessage[] {
|
||||
return [
|
||||
attachCodexMirrorIdentity(
|
||||
{
|
||||
role: "user",
|
||||
content: `benchmark prompt ${ordinal}`,
|
||||
timestamp: 2_000_000_000_000 + ordinal,
|
||||
} as AgentMessage,
|
||||
`turn-${ordinal}:prompt`,
|
||||
),
|
||||
attachCodexMirrorIdentity(
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: `benchmark final ${ordinal}` }],
|
||||
timestamp: 2_000_000_100_000 + ordinal,
|
||||
} as AgentMessage,
|
||||
`turn-${ordinal}:assistant`,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
async function runMirror(target: MirrorTarget, ordinal: number): Promise<void> {
|
||||
await codexTranscriptMirrorRuntime.mirror({
|
||||
...target,
|
||||
idempotencyScope: "codex-app-server:benchmark",
|
||||
messages: buildPromptFinalBatch(ordinal),
|
||||
});
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const sourceSha = readSourceSha();
|
||||
const eventCount = readIntegerArg("events", DEFAULT_EVENT_COUNT);
|
||||
const payloadBytes = readIntegerArg("payload-bytes", DEFAULT_PAYLOAD_BYTES);
|
||||
const runs = readIntegerArg("runs", DEFAULT_RUNS);
|
||||
const warmups = readIntegerArg("warmups", DEFAULT_WARMUPS);
|
||||
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-codex-mirror-bench-"));
|
||||
const agentId = "benchmark";
|
||||
const sessionId = "codex-mirror-benchmark";
|
||||
const sessionKey = `agent:${agentId}:${sessionId}`;
|
||||
try {
|
||||
const database = openOpenClawAgentDatabase({
|
||||
agentId,
|
||||
path: path.join(stateDir, "openclaw-agent.sqlite"),
|
||||
});
|
||||
await upsertSessionEntry(
|
||||
{ agentId, sessionKey, storePath: database.path },
|
||||
{ sessionId, updatedAt: 1 },
|
||||
);
|
||||
seedTranscript({
|
||||
database,
|
||||
eventCount,
|
||||
payloadText: "x".repeat(payloadBytes),
|
||||
sessionId,
|
||||
});
|
||||
const target = { agentId, sessionId, sessionKey, storePath: database.path };
|
||||
const instrumentation = instrumentWork(database);
|
||||
try {
|
||||
for (let ordinal = 0; ordinal < warmups; ordinal += 1) {
|
||||
await runMirror(target, ordinal);
|
||||
}
|
||||
instrumentation.reset();
|
||||
const beforeMaxRssKb = process.resourceUsage().maxRSS;
|
||||
const durations: number[] = [];
|
||||
for (let run = 0; run < runs; run += 1) {
|
||||
const startedAt = performance.now();
|
||||
await runMirror(target, warmups + run);
|
||||
durations.push(performance.now() - startedAt);
|
||||
}
|
||||
const afterMaxRssKb = process.resourceUsage().maxRSS;
|
||||
const measuredWork = { ...instrumentation.counters };
|
||||
const lastOrdinal = warmups + runs - 1;
|
||||
await runMirror(target, lastOrdinal);
|
||||
const row = database.db
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS count,
|
||||
SUM(LENGTH(CAST(event_json AS BLOB))) AS bytes
|
||||
FROM transcript_events
|
||||
WHERE session_id = ?`,
|
||||
)
|
||||
.get(sessionId) as { bytes: number; count: number };
|
||||
const expectedEvents = eventCount + NEW_MESSAGES_PER_OPERATION * (warmups + runs);
|
||||
if (row.count !== expectedEvents) {
|
||||
throw new Error(`mirror wrote ${row.count} events; expected ${expectedEvents}`);
|
||||
}
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
sourceSha,
|
||||
fixture: {
|
||||
initialMessageEvents: eventCount,
|
||||
payloadBytes,
|
||||
sqliteTranscriptBytesAfterOperations: row.bytes,
|
||||
},
|
||||
operation: "real Codex mirror owner with one new prompt and one new final",
|
||||
runtime: {
|
||||
arch: process.arch,
|
||||
node: process.version,
|
||||
platform: `${os.platform()} ${os.release()}`,
|
||||
},
|
||||
warmups,
|
||||
runs,
|
||||
latencyMs: {
|
||||
median: median(durations),
|
||||
p95: percentile(durations, 0.95),
|
||||
raw: durations.map((value) => Number(value.toFixed(3))),
|
||||
},
|
||||
memoryProxy: {
|
||||
maxRssKbBeforeOperations: beforeMaxRssKb,
|
||||
maxRssKbAfterOperations: afterMaxRssKb,
|
||||
maxRssGrowthKb: Math.max(0, afterMaxRssKb - beforeMaxRssKb),
|
||||
},
|
||||
measuredWork: {
|
||||
...measuredWork,
|
||||
perOperation: {
|
||||
fullTranscriptQueries: Number(
|
||||
(measuredWork.fullTranscriptQueries / runs).toFixed(3),
|
||||
),
|
||||
seededEventJsonParses: Number(
|
||||
(measuredWork.seededEventJsonParses / runs).toFixed(3),
|
||||
),
|
||||
selectQueries: Number((measuredWork.selectQueries / runs).toFixed(3)),
|
||||
},
|
||||
},
|
||||
correctness: {
|
||||
idempotentReplayAddedRows: 0,
|
||||
storedEventCount: row.count,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
instrumentation.restore();
|
||||
}
|
||||
} finally {
|
||||
closeOpenClawAgentDatabasesForTest();
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
fs.rmSync(stateDir, { force: true, recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
await main();
|
||||
@@ -836,7 +836,6 @@ function sanitizedEnv(
|
||||
TMPDIR: process.env.TMPDIR,
|
||||
USER: process.env.USER ?? "openclaw-bench",
|
||||
npm_config_update_notifier: "false",
|
||||
OPENCLAW_CONFIG: configPath,
|
||||
OPENCLAW_CONFIG_PATH: configPath,
|
||||
OPENCLAW_GATEWAY_RESTART_TRACE: "1",
|
||||
OPENCLAW_GATEWAY_STARTUP_TRACE: "1",
|
||||
|
||||
@@ -18,12 +18,17 @@ import {
|
||||
import { selectSlowStartupTraceDurations } from "./lib/gateway-startup-trace-ranking.js";
|
||||
|
||||
type GatewayBenchCase = {
|
||||
agentTopology?: "single" | "shared-eleven-plus-distinct-one";
|
||||
completionTracePhase?: string;
|
||||
config: Record<string, unknown>;
|
||||
env?: Record<string, string>;
|
||||
id: string;
|
||||
name: string;
|
||||
pluginActivationOnStartup?: boolean;
|
||||
pluginCount?: number;
|
||||
providerCatalogStallMs?: number;
|
||||
providerStaticCatalogModelCount?: number;
|
||||
providerStaticCatalogStallMs?: number;
|
||||
};
|
||||
|
||||
type ProbeResult = {
|
||||
@@ -41,6 +46,7 @@ type ProbeTransition = {
|
||||
};
|
||||
|
||||
type GatewaySample = {
|
||||
completionMs: number | null;
|
||||
cpuCoreRatio: number | null;
|
||||
cpuMs: number | null;
|
||||
exitedBeforeTeardown?: boolean;
|
||||
@@ -71,6 +77,7 @@ type CaseResult = {
|
||||
name: string;
|
||||
samples: GatewaySample[];
|
||||
summary: {
|
||||
completionMs: SummaryStats | null;
|
||||
firstOutputMs: SummaryStats | null;
|
||||
cpuCoreRatio: SummaryStats | null;
|
||||
cpuMs: SummaryStats | null;
|
||||
@@ -98,6 +105,7 @@ type CliOptions = {
|
||||
cases: GatewayBenchCase[];
|
||||
cpuProfDir?: string;
|
||||
entry: string;
|
||||
heapProfDir?: string;
|
||||
json: boolean;
|
||||
output?: string;
|
||||
runs: number;
|
||||
@@ -114,6 +122,7 @@ const VALUE_FLAGS = new Set([
|
||||
"--case",
|
||||
"--cpu-prof-dir",
|
||||
"--entry",
|
||||
"--heap-prof-dir",
|
||||
"--output",
|
||||
"--runs",
|
||||
"--timeout-ms",
|
||||
@@ -141,6 +150,9 @@ const BASE_CONFIG = {
|
||||
},
|
||||
} satisfies Record<string, unknown>;
|
||||
|
||||
const STALLED_CATALOG_PROVIDER_ID = "bench-catalog-stall";
|
||||
const STALLED_CATALOG_MODEL_ID = "bench-model";
|
||||
|
||||
const GATEWAY_CASES: readonly GatewayBenchCase[] = [
|
||||
{
|
||||
id: "default",
|
||||
@@ -153,6 +165,69 @@ const GATEWAY_CASES: readonly GatewayBenchCase[] = [
|
||||
env: { OPENCLAW_SKIP_CHANNELS: "1" },
|
||||
config: BASE_CONFIG,
|
||||
},
|
||||
{
|
||||
id: "preparedRuntimeCatalogStall",
|
||||
name: "gateway, prepared runtime with CPU-stalling live catalog",
|
||||
env: { OPENCLAW_SKIP_CHANNELS: "1" },
|
||||
providerCatalogStallMs: 2_000,
|
||||
config: {
|
||||
...BASE_CONFIG,
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: `${STALLED_CATALOG_PROVIDER_ID}/${STALLED_CATALOG_MODEL_ID}` },
|
||||
models: {
|
||||
[`${STALLED_CATALOG_PROVIDER_ID}/${STALLED_CATALOG_MODEL_ID}`]: {
|
||||
agentRuntime: { id: "openclaw" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "preparedRuntimeScaleOne",
|
||||
name: "gateway, prepared runtime scale with one agent",
|
||||
agentTopology: "single",
|
||||
completionTracePhase: "sidecars.ready",
|
||||
env: { OPENCLAW_SKIP_CHANNELS: "1" },
|
||||
providerStaticCatalogModelCount: 64,
|
||||
providerStaticCatalogStallMs: 100,
|
||||
config: {
|
||||
...BASE_CONFIG,
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: `${STALLED_CATALOG_PROVIDER_ID}/${STALLED_CATALOG_MODEL_ID}` },
|
||||
models: {
|
||||
[`${STALLED_CATALOG_PROVIDER_ID}/${STALLED_CATALOG_MODEL_ID}`]: {
|
||||
agentRuntime: { id: "openclaw" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "preparedRuntimeScaleMany",
|
||||
name: "gateway, prepared runtime scale with 11 shared-workspace agents and one distinct",
|
||||
agentTopology: "shared-eleven-plus-distinct-one",
|
||||
completionTracePhase: "sidecars.ready",
|
||||
env: { OPENCLAW_SKIP_CHANNELS: "1" },
|
||||
providerStaticCatalogModelCount: 64,
|
||||
providerStaticCatalogStallMs: 100,
|
||||
config: {
|
||||
...BASE_CONFIG,
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: `${STALLED_CATALOG_PROVIDER_ID}/${STALLED_CATALOG_MODEL_ID}` },
|
||||
models: {
|
||||
[`${STALLED_CATALOG_PROVIDER_ID}/${STALLED_CATALOG_MODEL_ID}`]: {
|
||||
agentRuntime: { id: "openclaw" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "oneInternalHook",
|
||||
name: "gateway, one configured internal hook",
|
||||
@@ -314,6 +389,7 @@ function parseOptions(argv: string[] = process.argv.slice(2)): CliOptions {
|
||||
cases: resolveCases(parseRepeatableFlag(argv, "--case")),
|
||||
cpuProfDir: parseFlagValue(argv, "--cpu-prof-dir"),
|
||||
entry: resolveEntry(parseFlagValue(argv, "--entry")),
|
||||
heapProfDir: parseFlagValue(argv, "--heap-prof-dir"),
|
||||
json: hasFlag(argv, "--json"),
|
||||
output: resolveOutputPath(parseFlagValue(argv, "--output")),
|
||||
runs: parsePositiveInt(parseFlagValue(argv, "--runs"), DEFAULT_RUNS, "--runs"),
|
||||
@@ -340,6 +416,7 @@ Options:
|
||||
--warmup <n> Warmup runs per case (default: ${DEFAULT_WARMUP})
|
||||
--timeout-ms <ms> Per-run timeout (default: ${DEFAULT_TIMEOUT_MS})
|
||||
--cpu-prof-dir <dir> Write one V8 CPU profile per run
|
||||
--heap-prof-dir <dir> Write one V8 heap profile per run
|
||||
--output <path> Write machine-readable JSON to a file
|
||||
--json Emit machine-readable JSON
|
||||
--help, -h Show this text
|
||||
@@ -405,6 +482,11 @@ function summarizeCase(benchCase: GatewayBenchCase, samples: GatewaySample[]): C
|
||||
name: benchCase.name,
|
||||
samples,
|
||||
summary: {
|
||||
completionMs: summarizeNumbers(
|
||||
samples
|
||||
.map((sample) => sample.completionMs)
|
||||
.filter((value): value is number => typeof value === "number"),
|
||||
),
|
||||
firstOutputMs: summarizeNumbers(
|
||||
samples
|
||||
.map((sample) => sample.firstOutputMs)
|
||||
@@ -465,6 +547,9 @@ function collectResultFailures(
|
||||
if (sample.readyz.status !== 200 || sample.readyz.ms == null) {
|
||||
missing.push("/readyz");
|
||||
}
|
||||
if (sample.completionMs == null) {
|
||||
missing.push("completion");
|
||||
}
|
||||
if (processMetricsRequired) {
|
||||
if (sample.cpuMs == null || sample.cpuCoreRatio == null) {
|
||||
missing.push("cpu");
|
||||
@@ -534,7 +619,7 @@ function formatRatio(value: number | null): string {
|
||||
return value.toFixed(3);
|
||||
}
|
||||
|
||||
function formatStats(stats: SummaryStats | null): string {
|
||||
function formatStats(stats: SummaryStats | null | undefined): string {
|
||||
if (!stats) {
|
||||
return "n/a";
|
||||
}
|
||||
@@ -608,21 +693,77 @@ async function waitForProbe(params: {
|
||||
return { firstErrorKind, firstRecoveryMs, ms: null, status: lastStatus, transitions };
|
||||
}
|
||||
|
||||
async function waitForStartupTracePhase(params: {
|
||||
deadlineAt: number;
|
||||
isDone: () => boolean;
|
||||
phase: string;
|
||||
startupTrace: Record<string, number>;
|
||||
}): Promise<number | null> {
|
||||
const totalKey = `${params.phase}.total`;
|
||||
while (performance.now() < params.deadlineAt) {
|
||||
if (Object.hasOwn(params.startupTrace, totalKey)) {
|
||||
return params.startupTrace[totalKey] ?? null;
|
||||
}
|
||||
if (params.isDone()) {
|
||||
return null;
|
||||
}
|
||||
await delay(25);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function writePluginFixtures(
|
||||
root: string,
|
||||
count: number,
|
||||
activationOnStartup?: boolean,
|
||||
providerCatalogStallMs?: number,
|
||||
providerStaticCatalogStallMs?: number,
|
||||
providerStaticCatalogModelCount?: number,
|
||||
): PluginFixtureResult {
|
||||
const pluginIds: string[] = [];
|
||||
const pluginsDir = path.join(root, "plugins");
|
||||
mkdirSync(pluginsDir, { recursive: true });
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const id = `bench-plugin-${String(index + 1).padStart(2, "0")}`;
|
||||
const stallsProviderCatalog = providerCatalogStallMs !== undefined && index === 0;
|
||||
const stallsProviderStaticCatalog = providerStaticCatalogStallMs !== undefined && index === 0;
|
||||
pluginIds.push(id);
|
||||
const pluginDir = path.join(pluginsDir, id);
|
||||
mkdirSync(pluginDir, { recursive: true });
|
||||
const entry = path.join(pluginDir, "index.cjs");
|
||||
writeFileSync(entry, `module.exports = { id: ${JSON.stringify(id)}, register() {} };\n`);
|
||||
const providerDiscoveryEntry = path.join(pluginDir, "provider-discovery.cjs");
|
||||
const models = Array.from(
|
||||
{ length: stallsProviderStaticCatalog ? (providerStaticCatalogModelCount ?? 1) : 1 },
|
||||
(_, modelIndex) => ({
|
||||
id:
|
||||
modelIndex === 0
|
||||
? STALLED_CATALOG_MODEL_ID
|
||||
: `${STALLED_CATALOG_MODEL_ID}-${modelIndex + 1}`,
|
||||
name: `Benchmark Model ${modelIndex + 1}`,
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 128_000,
|
||||
maxTokens: 8_192,
|
||||
}),
|
||||
);
|
||||
const provider = {
|
||||
baseUrl: "http://127.0.0.1:1/v1",
|
||||
api: "openai-completions",
|
||||
models,
|
||||
};
|
||||
const entrySource = stallsProviderCatalog
|
||||
? `const provider = ${JSON.stringify(provider)};\nmodule.exports = { id: ${JSON.stringify(id)}, register(api) { api.registerProvider({ id: ${JSON.stringify(STALLED_CATALOG_PROVIDER_ID)}, label: "Benchmark Catalog Stall", auth: [], catalog: { order: "simple", run: async () => { const stopAt = Date.now() + ${providerCatalogStallMs}; while (Date.now() < stopAt) {} return { provider }; } }, staticCatalog: { order: "simple", run: async () => ({ provider }) } }); } };\n`
|
||||
: stallsProviderStaticCatalog
|
||||
? `const provider = ${JSON.stringify(provider)};\nmodule.exports = { id: ${JSON.stringify(id)}, register(api) { api.registerProvider({ id: ${JSON.stringify(STALLED_CATALOG_PROVIDER_ID)}, label: "Benchmark Static Catalog Stall", auth: [], staticCatalog: { order: "simple", run: async () => ({ provider }) } }); } };\n`
|
||||
: `module.exports = { id: ${JSON.stringify(id)}, register() {} };\n`;
|
||||
writeFileSync(entry, entrySource);
|
||||
if (stallsProviderStaticCatalog) {
|
||||
writeFileSync(
|
||||
providerDiscoveryEntry,
|
||||
`const provider = ${JSON.stringify(provider)};\nlet staticCatalogCallCount = 0;\nmodule.exports = { id: ${JSON.stringify(STALLED_CATALOG_PROVIDER_ID)}, label: "Benchmark Static Catalog Stall", auth: [], staticCatalog: { order: "simple", run: async () => { staticCatalogCallCount += 1; console.log("startup trace: benchmark preparedRuntimeStaticCatalogCallCount=" + staticCatalogCallCount); const stopAt = Date.now() + ${providerStaticCatalogStallMs}; while (Date.now() < stopAt) {} return { provider }; } } };\n`,
|
||||
);
|
||||
}
|
||||
writeFileSync(
|
||||
path.join(pluginDir, "openclaw.plugin.json"),
|
||||
`${JSON.stringify(
|
||||
@@ -631,6 +772,21 @@ function writePluginFixtures(
|
||||
...(activationOnStartup === undefined
|
||||
? {}
|
||||
: { activation: { onStartup: activationOnStartup } }),
|
||||
...(stallsProviderCatalog || stallsProviderStaticCatalog
|
||||
? {
|
||||
providers: [STALLED_CATALOG_PROVIDER_ID],
|
||||
...(stallsProviderStaticCatalog
|
||||
? { providerCatalogEntry: "./provider-discovery.cjs" }
|
||||
: {}),
|
||||
...(stallsProviderCatalog
|
||||
? {
|
||||
modelCatalog: {
|
||||
providers: { [STALLED_CATALOG_PROVIDER_ID]: provider },
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
: {}),
|
||||
configSchema: { type: "object", additionalProperties: false },
|
||||
},
|
||||
null,
|
||||
@@ -641,12 +797,53 @@ function writePluginFixtures(
|
||||
return { pluginIds, pluginsDir };
|
||||
}
|
||||
|
||||
function buildBenchAgentList(
|
||||
root: string,
|
||||
topology: GatewayBenchCase["agentTopology"],
|
||||
): Array<{ id: string; default?: boolean; workspace: string }> | undefined {
|
||||
if (!topology) {
|
||||
return undefined;
|
||||
}
|
||||
const sharedWorkspace = path.join(root, "shared-workspace");
|
||||
const distinctWorkspace = path.join(root, "distinct-workspace");
|
||||
mkdirSync(sharedWorkspace, { recursive: true });
|
||||
if (topology === "single") {
|
||||
return [{ id: "main", default: true, workspace: sharedWorkspace }];
|
||||
}
|
||||
mkdirSync(distinctWorkspace, { recursive: true });
|
||||
return Array.from({ length: 12 }, (_, index) => ({
|
||||
id: `agent-${String(index + 1).padStart(2, "0")}`,
|
||||
...(index === 0 ? { default: true } : {}),
|
||||
workspace: index === 11 ? distinctWorkspace : sharedWorkspace,
|
||||
}));
|
||||
}
|
||||
|
||||
function writeConfig(root: string, benchCase: GatewayBenchCase): string {
|
||||
const pluginFixtures = benchCase.pluginCount
|
||||
? writePluginFixtures(root, benchCase.pluginCount, benchCase.pluginActivationOnStartup)
|
||||
const hasCatalogFixture =
|
||||
benchCase.providerCatalogStallMs !== undefined ||
|
||||
benchCase.providerStaticCatalogStallMs !== undefined;
|
||||
const pluginCount = hasCatalogFixture ? 1 : benchCase.pluginCount;
|
||||
const pluginFixtures = pluginCount
|
||||
? writePluginFixtures(
|
||||
root,
|
||||
pluginCount,
|
||||
hasCatalogFixture ? true : benchCase.pluginActivationOnStartup,
|
||||
benchCase.providerCatalogStallMs,
|
||||
benchCase.providerStaticCatalogStallMs,
|
||||
benchCase.providerStaticCatalogModelCount,
|
||||
)
|
||||
: null;
|
||||
const agentList = buildBenchAgentList(root, benchCase.agentTopology);
|
||||
const config = {
|
||||
...benchCase.config,
|
||||
...(agentList
|
||||
? {
|
||||
agents: {
|
||||
...(benchCase.config.agents as Record<string, unknown> | undefined),
|
||||
list: agentList,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
plugins: {
|
||||
...(benchCase.config.plugins as Record<string, unknown> | undefined),
|
||||
...(pluginFixtures
|
||||
@@ -678,7 +875,6 @@ function sanitizedEnv(
|
||||
TMPDIR: process.env.TMPDIR,
|
||||
USER: process.env.USER ?? "openclaw-bench",
|
||||
npm_config_update_notifier: "false",
|
||||
OPENCLAW_CONFIG: configPath,
|
||||
OPENCLAW_CONFIG_PATH: configPath,
|
||||
OPENCLAW_GATEWAY_STARTUP_TRACE: "1",
|
||||
OPENCLAW_HOME: root,
|
||||
@@ -751,6 +947,7 @@ async function runGatewaySample(options: {
|
||||
benchCase: GatewayBenchCase;
|
||||
cpuProfDir?: string;
|
||||
entry: string;
|
||||
heapProfDir?: string;
|
||||
sampleIndex: number;
|
||||
timeoutMs: number;
|
||||
}): Promise<GatewaySample> {
|
||||
@@ -780,6 +977,7 @@ async function runGatewaySample(options: {
|
||||
`openclaw-gateway-${options.benchCase.id}-${options.sampleIndex}-${Date.now()}.cpuprofile`,
|
||||
]
|
||||
: []),
|
||||
...(options.heapProfDir ? ["--heap-prof", "--heap-prof-dir", options.heapProfDir] : []),
|
||||
options.entry,
|
||||
"gateway",
|
||||
"run",
|
||||
@@ -858,10 +1056,18 @@ async function runGatewaySample(options: {
|
||||
startAt,
|
||||
}),
|
||||
]);
|
||||
const readyAt = performance.now();
|
||||
const completionMs = options.benchCase.completionTracePhase
|
||||
? await waitForStartupTracePhase({
|
||||
deadlineAt,
|
||||
isDone: () => childExited,
|
||||
phase: options.benchCase.completionTracePhase,
|
||||
startupTrace,
|
||||
})
|
||||
: performance.now() - startAt;
|
||||
const completedAt = performance.now();
|
||||
const cpuEndMs = readProcessTreeCpuMs(child.pid);
|
||||
const cpuMs = cpuStartMs == null || cpuEndMs == null ? null : Math.max(0, cpuEndMs - cpuStartMs);
|
||||
const cpuCoreRatio = cpuMs == null ? null : cpuMs / Math.max(1, readyAt - startAt);
|
||||
const cpuCoreRatio = cpuMs == null ? null : cpuMs / Math.max(1, completedAt - startAt);
|
||||
const exit = await stopChild(child);
|
||||
clearInterval(rssTimer);
|
||||
sampleRss();
|
||||
@@ -870,6 +1076,7 @@ async function runGatewaySample(options: {
|
||||
rmSync(root, { force: true, maxRetries: 3, recursive: true, retryDelay: 100 });
|
||||
|
||||
return {
|
||||
completionMs,
|
||||
cpuCoreRatio,
|
||||
cpuMs,
|
||||
exitedBeforeTeardown: exit.exitedBeforeTeardown,
|
||||
@@ -892,6 +1099,7 @@ async function runCase(options: {
|
||||
benchCase: GatewayBenchCase;
|
||||
cpuProfDir?: string;
|
||||
entry: string;
|
||||
heapProfDir?: string;
|
||||
runs: number;
|
||||
timeoutMs: number;
|
||||
warmup: number;
|
||||
@@ -903,6 +1111,7 @@ async function runCase(options: {
|
||||
benchCase: options.benchCase,
|
||||
cpuProfDir: options.cpuProfDir,
|
||||
entry: options.entry,
|
||||
heapProfDir: options.heapProfDir,
|
||||
sampleIndex: index + 1,
|
||||
timeoutMs: options.timeoutMs,
|
||||
});
|
||||
@@ -910,8 +1119,18 @@ async function runCase(options: {
|
||||
samples.push(sample);
|
||||
const heapUsedMb = sample.startupTrace["memory.ready.heapUsedMb"] ?? null;
|
||||
console.log(
|
||||
`[gateway-startup-bench] ${options.benchCase.id} run ${samples.length}/${options.runs}: healthz=${formatMs(sample.healthz.ms)} readyz=${formatMs(sample.readyz.ms)} httpListen=${formatMs(sample.httpListenLogMs)} gatewayReady=${formatMs(sample.gatewayReadyLogMs)} cpu=${formatMs(sample.cpuMs)} cpuCore=${formatRatio(sample.cpuCoreRatio)} rss=${formatMb(sample.maxRssMb)} heap=${formatMb(heapUsedMb)}`,
|
||||
`[gateway-startup-bench] ${options.benchCase.id} run ${samples.length}/${options.runs}: completion=${formatMs(sample.completionMs)} healthz=${formatMs(sample.healthz.ms)} readyz=${formatMs(sample.readyz.ms)} httpListen=${formatMs(sample.httpListenLogMs)} gatewayReady=${formatMs(sample.gatewayReadyLogMs)} cpu=${formatMs(sample.cpuMs)} cpuCore=${formatRatio(sample.cpuCoreRatio)} rss=${formatMb(sample.maxRssMb)} heap=${formatMb(heapUsedMb)}`,
|
||||
);
|
||||
if (
|
||||
sample.outputTail &&
|
||||
(sample.completionMs == null ||
|
||||
sample.healthz.status !== 200 ||
|
||||
sample.readyz.status !== 200)
|
||||
) {
|
||||
console.error(
|
||||
`[gateway-startup-bench] ${options.benchCase.id} output tail:\n${sample.outputTail}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const heapUsedMb = sample.startupTrace["memory.ready.heapUsedMb"] ?? null;
|
||||
console.log(
|
||||
@@ -924,6 +1143,7 @@ async function runCase(options: {
|
||||
|
||||
function printResult(result: CaseResult): void {
|
||||
console.log(`\n${result.name} (${result.id})`);
|
||||
console.log(` completion: ${formatStats(result.summary.completionMs)}`);
|
||||
console.log(` first output: ${formatStats(result.summary.firstOutputMs)}`);
|
||||
console.log(` CPU: ${formatStats(result.summary.cpuMs)}`);
|
||||
console.log(` CPU core: ${formatRatioStats(result.summary.cpuCoreRatio)}`);
|
||||
@@ -938,6 +1158,9 @@ function printResult(result: CaseResult): void {
|
||||
console.log(
|
||||
` post-ready memory: rss=${formatMemoryStats(result.summary.startupTrace["memory.post-ready.rssMb"])} heap=${formatMemoryStats(result.summary.startupTrace["memory.post-ready.heapUsedMb"])} external=${formatMemoryStats(result.summary.startupTrace["memory.post-ready.externalMb"])}`,
|
||||
);
|
||||
console.log(
|
||||
` prepared runtime: agents=${formatStats(result.summary.startupTrace["sidecars.model-runtime-build.agentCount"])} workspaces=${formatStats(result.summary.startupTrace["sidecars.model-runtime-build.workspaceGroupCount"])} configuredGroups=${formatStats(result.summary.startupTrace["sidecars.model-runtime-build.configuredFactsGroupCount"])} configuredModels=${formatStats(result.summary.startupTrace["sidecars.model-runtime-build.configuredRuntimeModelCount"])} generatedPlugins=${formatStats(result.summary.startupTrace["sidecars.model-runtime-build.generatedCatalogPluginCount"])} generatedReads=${formatStats(result.summary.startupTrace["sidecars.model-runtime-build.generatedCatalogReadCount"])} sources=${formatStats(result.summary.startupTrace["sidecars.model-runtime-build.catalogSourceCount"])} credentials=${formatStats(result.summary.startupTrace["sidecars.model-runtime-build.credentialGroupCount"])} catalogs=${formatStats(result.summary.startupTrace["sidecars.model-runtime-build.catalogGroupCount"])} registries=${formatStats(result.summary.startupTrace["sidecars.model-runtime-build.runtimeRegistryCount"])} workspaceFacts=${formatStats(result.summary.startupTrace["sidecars.model-runtime-build.workspaceFactsMs"])} runtimePlugins=${formatStats(result.summary.startupTrace["sidecars.model-runtime-build.runtimePluginMs"])} metadata=${formatStats(result.summary.startupTrace["sidecars.model-runtime-build.pluginMetadataMs"])} staticProviders=${formatStats(result.summary.startupTrace["sidecars.model-runtime-build.staticProviderCatalogMs"])} ambientAuth=${formatStats(result.summary.startupTrace["sidecars.model-runtime-build.ambientCredentialsMs"])} agentFacts=${formatStats(result.summary.startupTrace["sidecars.model-runtime-build.agentFactsMs"])} configuredProjection=${formatStats(result.summary.startupTrace["sidecars.model-runtime-build.configuredProjectionMs"])} sourceMs=${formatStats(result.summary.startupTrace["sidecars.model-runtime-build.catalogSourceMs"])} registryMs=${formatStats(result.summary.startupTrace["sidecars.model-runtime-build.registryMs"])} sourceLimit=${formatStats(result.summary.startupTrace["sidecars.model-runtime-build.sourceConcurrencyLimitCount"])} fullCatalogLimit=${formatStats(result.summary.startupTrace["sidecars.model-runtime-build.fullCatalogConcurrencyLimitCount"])} staticCatalog=${formatStats(result.summary.startupTrace["benchmark.preparedRuntimeStaticCatalogCallCount"])} pluginLoader=${formatStats(result.summary.startupTrace["sidecars.plugin-loader.callsCount"])} eventLoopMax=${formatStats(result.summary.startupTrace["sidecars.model-runtime.eventLoopMax"])}`,
|
||||
);
|
||||
const trace = selectSlowStartupTraceDurations(result.summary.startupTrace, 8);
|
||||
if (trace.length > 0) {
|
||||
console.log(" trace top:");
|
||||
@@ -958,6 +1181,9 @@ async function main() {
|
||||
if (options.cpuProfDir) {
|
||||
mkdirSync(options.cpuProfDir, { recursive: true });
|
||||
}
|
||||
if (options.heapProfDir) {
|
||||
mkdirSync(options.heapProfDir, { recursive: true });
|
||||
}
|
||||
const results: CaseResult[] = [];
|
||||
for (const benchCase of options.cases) {
|
||||
results.push(
|
||||
@@ -965,6 +1191,7 @@ async function main() {
|
||||
benchCase,
|
||||
cpuProfDir: options.cpuProfDir,
|
||||
entry: options.entry,
|
||||
heapProfDir: options.heapProfDir,
|
||||
runs: options.runs,
|
||||
timeoutMs: options.timeoutMs,
|
||||
warmup: options.warmup,
|
||||
@@ -1010,6 +1237,7 @@ export const testing = {
|
||||
summarizeCase,
|
||||
validateCliArgs,
|
||||
waitForProbe,
|
||||
waitForStartupTracePhase,
|
||||
writeConfig,
|
||||
};
|
||||
|
||||
|
||||
@@ -33,6 +33,24 @@ function printProofLines(report: ReliabilityReport): void {
|
||||
);
|
||||
console.log(`SQLITE_RELIABILITY_RESTORES_VERIFIED=${report.restoresVerified}`);
|
||||
console.log(`SQLITE_RELIABILITY_WRITER_ROWS=${report.writer.rowsCommitted}`);
|
||||
console.log(
|
||||
`SQLITE_RELIABILITY_CRASH_RECOVERY=${report.crashRecoveryProof.sourceRecovered && report.crashRecoveryProof.committedStatePreserved && report.crashRecoveryProof.writerRestarted ? "verified" : "missing"}`,
|
||||
);
|
||||
console.log(
|
||||
`SQLITE_RELIABILITY_CRASH_EXIT_SIGNAL=${report.crashRecoveryProof.exit.signal ?? "none"}`,
|
||||
);
|
||||
console.log(
|
||||
`SQLITE_RELIABILITY_PUBLICATION_INTERRUPTION=${report.publicationInterruptionProof.beforePublish.recoveryVerified && report.publicationInterruptionProof.afterPublish.targetVerifiedAfterCrash && report.publicationInterruptionProof.afterPublish.recoveryVerified ? "verified" : "missing"}`,
|
||||
);
|
||||
console.log(
|
||||
`SQLITE_RELIABILITY_RESTORE_INTERRUPTION=${report.maintenanceProof.restoreInterruption.beforePublish.recoveryVerified && report.maintenanceProof.restoreInterruption.beforePublish.retryRestored && report.maintenanceProof.restoreInterruption.afterPublish.targetVerifiedAfterCrash && report.maintenanceProof.restoreInterruption.afterPublish.existingTargetPreserved ? "verified" : "missing"}`,
|
||||
);
|
||||
console.log(
|
||||
`SQLITE_RELIABILITY_REPOSITORY_INTERRUPTION=${report.maintenanceProof.repositoryInterruption.beforePending.repositoryVerified && report.maintenanceProof.repositoryInterruption.beforePending.retryCreated && report.maintenanceProof.repositoryInterruption.pending.crashSnapshotVerifiedAfterCrash && report.maintenanceProof.repositoryInterruption.pending.crashSnapshotVisibleAfterCrash && report.maintenanceProof.repositoryInterruption.pending.incompleteEntries === 0 && report.maintenanceProof.repositoryInterruption.pending.retryCreated && report.maintenanceProof.repositoryInterruption.afterCommit.crashSnapshotVerifiedAfterCrash && report.maintenanceProof.repositoryInterruption.afterCommit.retryCreated ? "verified" : "missing"}`,
|
||||
);
|
||||
console.log(
|
||||
`SQLITE_RELIABILITY_INDEX_REPAIR_INTERRUPTION=${report.indexRepairInterruptionProof.rollbackJournal.recoveryVerified && report.indexRepairInterruptionProof.wal.recoveryVerified ? "verified" : "missing"}`,
|
||||
);
|
||||
console.log(
|
||||
`SQLITE_RELIABILITY_WAL_SENTINEL=${report.transactionProof.committedWalSentinel ? "verified" : "missing"}`,
|
||||
);
|
||||
@@ -43,6 +61,9 @@ function printProofLines(report: ReliabilityReport): void {
|
||||
console.log(
|
||||
`SQLITE_RELIABILITY_COMPACT_RECLAIMED_BYTES=${report.maintenanceProof.compaction.reclaimedBytes}`,
|
||||
);
|
||||
console.log(
|
||||
`SQLITE_RELIABILITY_VACUUM_INTERRUPTION=${report.maintenanceProof.vacuumInterruption.recoveryVerified ? "verified" : "missing"}`,
|
||||
);
|
||||
console.log(
|
||||
`SQLITE_RELIABILITY_POST_COMPACT_RESTORE=${report.maintenanceProof.postCompact.restoreVerified ? "verified" : "missing"}`,
|
||||
);
|
||||
|
||||
@@ -47,13 +47,20 @@ function seedTranscript(
|
||||
const now = Date.now();
|
||||
database.db
|
||||
.prepare(
|
||||
`INSERT INTO sessions (session_id, session_key, session_scope, created_at, updated_at)
|
||||
VALUES (?, ?, 'conversation', ?, ?)`,
|
||||
`INSERT INTO session_nodes (session_key, current_session_id, entry_json, updated_at)
|
||||
VALUES (?, ?, '{}', ?)`,
|
||||
)
|
||||
.run(sessionKey, sessionId, now);
|
||||
database.db
|
||||
.prepare(
|
||||
`INSERT INTO session_windows (
|
||||
session_id, session_key, session_scope, created_at, updated_at
|
||||
) VALUES (?, ?, 'conversation', ?, ?)`,
|
||||
)
|
||||
.run(sessionId, sessionKey, now, now);
|
||||
database.db
|
||||
.prepare(
|
||||
`INSERT INTO session_transcript_generations (session_id, generation, updated_at)
|
||||
`INSERT INTO transcript_rewrite_watermarks (session_id, generation, updated_at)
|
||||
VALUES (?, 'benchmark-generation', ?)`,
|
||||
)
|
||||
.run(sessionId, now);
|
||||
|
||||
@@ -43,7 +43,6 @@ const TSDOWN_MAIN_PACKAGE_OUTPUT_ROOTS = TSDOWN_PACKAGE_OUTPUT_ROOTS.filter(
|
||||
const TSDOWN_DECLARATION_TOOL_INPUTS = [
|
||||
"package.json",
|
||||
"pnpm-lock.yaml",
|
||||
"npm-shrinkwrap.json",
|
||||
"tsconfig.json",
|
||||
"scripts/tsdown-build.mjs",
|
||||
"scripts/lib/bundled-plugin-build-entries.mjs",
|
||||
@@ -97,7 +96,6 @@ const PLUGIN_SDK_SELF_BUILT_ENTRY_DTS_CACHE_INPUTS = [
|
||||
...PLUGIN_SDK_ENTRY_DTS_SHARED_CACHE_INPUTS,
|
||||
"package.json",
|
||||
"pnpm-lock.yaml",
|
||||
"npm-shrinkwrap.json",
|
||||
"tsconfig.json",
|
||||
"tsconfig.plugin-sdk.dts.json",
|
||||
{
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Render the macOS .icon bundle to a padded .icns like Trimmy's pipeline.
|
||||
# Defaults target the OpenClaw assets so you can just run the script from repo root.
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
|
||||
ICON_FILE=${1:-"$ROOT_DIR/apps/macos/Icon.icon"}
|
||||
BASENAME=${2:-OpenClaw}
|
||||
OUT_ROOT=${3:-"$ROOT_DIR/apps/macos/build/icon"}
|
||||
XCODE_APP=${XCODE_APP:-/Applications/Xcode.app}
|
||||
# Where the final .icns should live; override DEST_ICNS to change.
|
||||
DEST_ICNS=${DEST_ICNS:-"$ROOT_DIR/apps/macos/Sources/OpenClaw/Resources/OpenClaw.icns"}
|
||||
|
||||
ICTOOL="$XCODE_APP/Contents/Applications/Icon Composer.app/Contents/Executables/ictool"
|
||||
if [[ ! -x "$ICTOOL" ]]; then
|
||||
ICTOOL="$XCODE_APP/Contents/Applications/Icon Composer.app/Contents/Executables/icontool"
|
||||
fi
|
||||
if [[ ! -x "$ICTOOL" ]]; then
|
||||
echo "ictool/icontool not found. Set XCODE_APP if Xcode is elsewhere." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ICONSET_DIR="$OUT_ROOT/${BASENAME}.iconset"
|
||||
TMP_DIR="$OUT_ROOT/tmp"
|
||||
mkdir -p "$ICONSET_DIR" "$TMP_DIR"
|
||||
|
||||
MASTER_ART="$TMP_DIR/icon_art_824.png"
|
||||
MASTER_1024="$TMP_DIR/icon_1024.png"
|
||||
|
||||
# Render inner art (no margin) with macOS Default appearance
|
||||
"$ICTOOL" "$ICON_FILE" \
|
||||
--export-preview macOS Default 824 824 1 -45 "$MASTER_ART"
|
||||
|
||||
# Pad to 1024x1024 with transparent border
|
||||
sips --padToHeightWidth 1024 1024 "$MASTER_ART" --out "$MASTER_1024" >/dev/null
|
||||
|
||||
# Generate required sizes
|
||||
sizes=(16 32 64 128 256 512 1024)
|
||||
for sz in "${sizes[@]}"; do
|
||||
out="$ICONSET_DIR/icon_${sz}x${sz}.png"
|
||||
sips -z "$sz" "$sz" "$MASTER_1024" --out "$out" >/dev/null
|
||||
if [[ "$sz" -ne 1024 ]]; then
|
||||
dbl=$((sz*2))
|
||||
out2="$ICONSET_DIR/icon_${sz}x${sz}@2x.png"
|
||||
sips -z "$dbl" "$dbl" "$MASTER_1024" --out "$out2" >/dev/null
|
||||
fi
|
||||
done
|
||||
|
||||
# 512x512@2x already covered by 1024; ensure it exists
|
||||
cp "$MASTER_1024" "$ICONSET_DIR/icon_512x512@2x.png"
|
||||
|
||||
iconutil -c icns "$ICONSET_DIR" -o "$OUT_ROOT/${BASENAME}.icns"
|
||||
|
||||
mkdir -p "$(dirname "$DEST_ICNS")"
|
||||
cp "$OUT_ROOT/${BASENAME}.icns" "$DEST_ICNS"
|
||||
|
||||
echo "Icon.icns generated at $DEST_ICNS"
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
exec node "$ROOT_DIR/scripts/bundle-a2ui.mjs" "$@"
|
||||
@@ -17,9 +17,14 @@ export function readBundledPluginAssetHooks(options?: Record<string, unknown>):
|
||||
*/
|
||||
export function runBundledPluginAssetHooks(options?: Record<string, unknown>): Promise<void>;
|
||||
/**
|
||||
* Parses `--phase` and repeated `--plugin` flags for asset hook scripts.
|
||||
* Lists declared generated source-tree outputs that differ from the committed bytes.
|
||||
*/
|
||||
export function listStaleGeneratedPluginAssets(options?: Record<string, unknown>): string[];
|
||||
/**
|
||||
* Parses `--phase`, repeated `--plugin`, and `--check` flags for asset hook scripts.
|
||||
*/
|
||||
export function parseBundledPluginAssetArgs(argv: unknown): {
|
||||
check: boolean;
|
||||
phase: unknown;
|
||||
plugins: unknown[];
|
||||
};
|
||||
|
||||
@@ -5,9 +5,14 @@ import { spawnSync } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { runManagedCommand } from "./lib/managed-child-process.mjs";
|
||||
import { assertRealOutputRoot } from "./lib/output-root-guard.mjs";
|
||||
import { listGeneratedExtensionAssetSources } from "./lib/static-extension-assets.mjs";
|
||||
|
||||
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const VALID_PHASES = new Set(["build", "copy"]);
|
||||
// Each complete bundled-plugin asset generator gets the same 10-minute build ceiling.
|
||||
const BUNDLED_PLUGIN_ASSET_HOOK_TIMEOUT_MS = 600_000;
|
||||
|
||||
async function readJsonFile(filePath) {
|
||||
return JSON.parse(await fs.readFile(filePath, "utf8"));
|
||||
@@ -100,7 +105,7 @@ export async function readBundledPluginAssetHooks(options = {}) {
|
||||
}
|
||||
|
||||
hooks.push({
|
||||
aliases: [...aliases].toSorted(),
|
||||
aliases: [...aliases].toSorted((left, right) => left.localeCompare(right)),
|
||||
command,
|
||||
packageName: packageJson.name,
|
||||
phase,
|
||||
@@ -117,34 +122,83 @@ export async function readBundledPluginAssetHooks(options = {}) {
|
||||
*/
|
||||
export async function runBundledPluginAssetHooks(options = {}) {
|
||||
const phase = options.phase;
|
||||
const timeoutMs = options.timeoutMs ?? BUNDLED_PLUGIN_ASSET_HOOK_TIMEOUT_MS;
|
||||
const hooks = await readBundledPluginAssetHooks(options);
|
||||
if (hooks.length === 0) {
|
||||
const scope = options.plugins?.length ? ` for ${options.plugins.join(", ")}` : "";
|
||||
console.log(`No bundled plugin asset ${phase} hooks${scope}; skipping.`);
|
||||
return;
|
||||
}
|
||||
if (phase === "copy") {
|
||||
assertRealOutputRoot(path.join(options.rootDir ?? rootDir, "dist"));
|
||||
}
|
||||
|
||||
for (const hook of hooks) {
|
||||
console.log(`[${hook.pluginId}] ${phase}: ${hook.command}`);
|
||||
const result = spawnSync(hook.command, {
|
||||
cwd: hook.pluginDir,
|
||||
env: process.env,
|
||||
shell: true,
|
||||
stdio: "inherit",
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
process.exit(result.status ?? 1);
|
||||
let status;
|
||||
try {
|
||||
status = await runManagedCommand({
|
||||
bin: hook.command,
|
||||
cwd: hook.pluginDir,
|
||||
env: process.env,
|
||||
shell: true,
|
||||
stdio: "inherit",
|
||||
timeoutMs,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error && typeof error === "object" && "code" in error && error.code === "ETIMEDOUT") {
|
||||
throw Object.assign(
|
||||
new Error(
|
||||
`Bundled plugin asset ${phase} hook timed out after ${timeoutMs}ms: ${hook.pluginId}`,
|
||||
),
|
||||
{ code: "ETIMEDOUT" },
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (status !== 0) {
|
||||
process.exit(status);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses `--phase` and repeated `--plugin` flags for asset hook scripts.
|
||||
* Lists declared generated source-tree outputs that differ from the committed
|
||||
* bytes. Committed buildOutputs must match a fresh hook run; PR node-test
|
||||
* selection skips extension suites for packages-only diffs, so this check is
|
||||
* the guard that keeps upstream changes from landing stale committed bundles.
|
||||
*/
|
||||
export function listStaleGeneratedPluginAssets(options = {}) {
|
||||
const repoRoot = options.rootDir ?? rootDir;
|
||||
const sources = listGeneratedExtensionAssetSources({ rootDir: repoRoot });
|
||||
if (sources.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const result = spawnSync("git", ["status", "--porcelain", "--", ...sources], {
|
||||
cwd: repoRoot,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
throw new Error(
|
||||
`git status failed for generated plugin assets: ${result.stderr?.trim() || result.status}`,
|
||||
);
|
||||
}
|
||||
return result.stdout
|
||||
.split("\n")
|
||||
.map((line) => line.slice(3).trim())
|
||||
.filter(Boolean)
|
||||
.toSorted((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses `--phase`, repeated `--plugin`, and `--check` flags for asset hook scripts.
|
||||
*/
|
||||
export function parseBundledPluginAssetArgs(argv) {
|
||||
const args = [...argv];
|
||||
const plugins = [];
|
||||
let phase = null;
|
||||
let check = false;
|
||||
|
||||
while (args.length > 0) {
|
||||
const arg = args.shift();
|
||||
@@ -167,19 +221,44 @@ export function parseBundledPluginAssetArgs(argv) {
|
||||
plugins.push(arg.slice("--plugin=".length));
|
||||
continue;
|
||||
}
|
||||
if (arg === "--check") {
|
||||
check = true;
|
||||
continue;
|
||||
}
|
||||
throw new Error(`Unknown bundled plugin asset argument: ${String(arg)}`);
|
||||
}
|
||||
|
||||
if (!VALID_PHASES.has(phase)) {
|
||||
throw new Error(`Expected --phase ${[...VALID_PHASES].join("|")}`);
|
||||
}
|
||||
// The stale-asset scan covers every declared buildOutput, so a filtered run
|
||||
// would fail on drift it never rebuilt; keep check runs whole-repo.
|
||||
if (check && phase !== "build") {
|
||||
throw new Error("--check requires --phase build");
|
||||
}
|
||||
if (check && plugins.length > 0) {
|
||||
throw new Error("--check cannot be combined with --plugin filters");
|
||||
}
|
||||
|
||||
return { phase, plugins };
|
||||
return { check, phase, plugins };
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
||||
try {
|
||||
await runBundledPluginAssetHooks(parseBundledPluginAssetArgs(process.argv.slice(2)));
|
||||
const args = parseBundledPluginAssetArgs(process.argv.slice(2));
|
||||
await runBundledPluginAssetHooks(args);
|
||||
if (args.check) {
|
||||
const stale = listStaleGeneratedPluginAssets();
|
||||
if (stale.length > 0) {
|
||||
console.error("Generated bundled plugin assets differ from the committed bytes:");
|
||||
for (const source of stale) {
|
||||
console.error(` - ${source}`);
|
||||
}
|
||||
console.error("Rebuild with `pnpm plugins:assets:build` and commit the regenerated files.");
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("Generated bundled plugin assets match the committed bytes.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
|
||||
@@ -10,6 +10,7 @@ export type ChangedLane =
|
||||
| "docs"
|
||||
| "tooling"
|
||||
| "liveDockerTooling"
|
||||
| "bundledChannelConfigMetadata"
|
||||
| "releaseMetadata"
|
||||
| "all";
|
||||
|
||||
@@ -48,6 +49,7 @@ export function listChangedPathsFromGit(params: {
|
||||
mergeHeadFirstParent?: boolean;
|
||||
}): string[];
|
||||
export function listStagedChangedPaths(cwd?: string): string[];
|
||||
export function hasDeadcodeScannedSource(changedPaths: string[]): boolean;
|
||||
export function isLiveDockerPackageScriptOnlyChange(before: string, after: string): boolean;
|
||||
export function isPackageScriptOnlyChange(before: string, after: string): boolean;
|
||||
|
||||
|
||||
+17
-16
@@ -1,4 +1,3 @@
|
||||
// Classifies changed files into CI lanes and release metadata scopes.
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { appendFileSync, existsSync, readFileSync } from "node:fs";
|
||||
import { booleanFlag, parseFlagArgs, stringFlag } from "./lib/arg-utils.mjs";
|
||||
@@ -9,6 +8,15 @@ import { resolveMergeHeadDiffBase } from "./lib/merge-head-diff-base.mjs";
|
||||
const GIT_OUTPUT_MAX_BUFFER = 64 * 1024 * 1024;
|
||||
const IMPLAUSIBLE_NO_MERGE_BASE_DIFF_PATHS = 200;
|
||||
const RAW_SYNC_CHANGED_LANES_ENV = "OPENCLAW_CHANGED_LANES_RAW_SYNC";
|
||||
// Source files knip's production scan reads. Any edit to one of these can orphan
|
||||
// an export -- including an import-only edit that drops a barrel re-export's last
|
||||
// consumer -- so the scan is selected by path, not by inspecting changed lines.
|
||||
const DEADCODE_SOURCE_PATH_RE = /^(?:src|extensions|ui|packages)\/.+\.[cm]?[jt]sx?$/u;
|
||||
|
||||
/** Returns whether any changed path is production source knip scans. */
|
||||
export function hasDeadcodeScannedSource(changedPaths) {
|
||||
return changedPaths.map(normalizeChangedPath).some((p) => DEADCODE_SOURCE_PATH_RE.test(p));
|
||||
}
|
||||
|
||||
const SCRIPTS_TYPECHECK_PATH_RE =
|
||||
/^(?:scripts\/.*\.(?:[cm]?ts|[cm]?tsx)|tsconfig\.scripts\.json)$/u;
|
||||
@@ -33,6 +41,8 @@ const LIVE_DOCKER_TOOLING_PATHS = new Set([
|
||||
const LIVE_DOCKER_PACKAGE_SCRIPT_RE = /^test:docker:live-[\w:-]+$/u;
|
||||
const PUBLIC_EXTENSION_CONTRACT_RE =
|
||||
/^(?:src\/plugin-sdk\/|src\/plugins\/contracts\/|src\/channels\/plugins\/|scripts\/lib\/plugin-sdk-entrypoints\.json$|scripts\/sync-plugin-sdk-exports\.mjs$|scripts\/generate-plugin-sdk-api-baseline\.ts$)/u;
|
||||
const BUNDLED_CHANNEL_CONFIG_METADATA_PATH_RE =
|
||||
/^(?:src\/config\/(?:bundled-channel-config-metadata\.generated|zod-schema\.[^/]+)\.ts|src\/channels\/plugins\/config-schema\.ts|src\/plugin-sdk\/(?:bundled-channel-config-schema|channel-config-schema)\.ts|src\/plugins\/(?:bundled-dir|public-surface-loader|public-surface-runtime|sdk-alias)\.ts|scripts\/(?:generate-bundled-channel-config-metadata\.ts|load-channel-config-surface\.ts|lib\/(?:bundled-plugin-source-utils|format-generated-module|generated-output-utils)\.mjs)|extensions\/[^/]+\/(?:openclaw\.plugin\.json|package\.json|(?:config|security-contract)-api\.[cm]?[jt]sx?|src\/config-(?:schema(?:-[^/]+)?|surface|ui-hints)\.[cm]?[jt]sx?))$/u;
|
||||
/**
|
||||
* Files whose changes are treated as release metadata only.
|
||||
* @internal Shared repository-script contract.
|
||||
@@ -51,7 +61,7 @@ export const RELEASE_METADATA_PATHS = new Set([
|
||||
"package.json",
|
||||
]);
|
||||
|
||||
/** @typedef {"core" | "coreTests" | "ui" | "extensions" | "extensionTests" | "scripts" | "testRoot" | "apps" | "docs" | "tooling" | "liveDockerTooling" | "releaseMetadata" | "all"} ChangedLane */
|
||||
/** @typedef {"core" | "coreTests" | "ui" | "extensions" | "extensionTests" | "scripts" | "testRoot" | "apps" | "docs" | "tooling" | "liveDockerTooling" | "bundledChannelConfigMetadata" | "releaseMetadata" | "all"} ChangedLane */
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
@@ -80,21 +90,16 @@ export function createEmptyChangedLanes() {
|
||||
docs: false,
|
||||
tooling: false,
|
||||
liveDockerTooling: false,
|
||||
bundledChannelConfigMetadata: false,
|
||||
releaseMetadata: false,
|
||||
all: false,
|
||||
};
|
||||
}
|
||||
|
||||
/** @internal Shared repository-script contract. */
|
||||
export function isChangedLaneTestPath(changedPath) {
|
||||
return getChangedPathFacts(normalizeChangedPath(changedPath)).isChangedLaneTest;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string[]} changedPaths
|
||||
* @param {{ packageJsonChangeKind?: "liveDockerTooling" | "tooling" | null }} [options]
|
||||
* @returns {ChangedLaneResult}
|
||||
*/
|
||||
/**
|
||||
* Classifies a list of changed paths into docs, app, extension, core, and tooling lanes.
|
||||
* @internal Shared repository-script contract.
|
||||
@@ -133,6 +138,10 @@ export function detectChangedLanes(changedPaths, options = {}) {
|
||||
|
||||
for (const changedPath of paths) {
|
||||
const facts = getChangedPathFacts(changedPath);
|
||||
if (BUNDLED_CHANNEL_CONFIG_METADATA_PATH_RE.test(changedPath)) {
|
||||
lanes.bundledChannelConfigMetadata = true;
|
||||
reasons.push(`${changedPath}: bundled channel config metadata input`);
|
||||
}
|
||||
if (SCRIPTS_TYPECHECK_PATH_RE.test(changedPath)) {
|
||||
lanes.scripts = true;
|
||||
}
|
||||
@@ -256,10 +265,6 @@ export function detectChangedLanes(changedPaths, options = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ paths: string[]; base: string; head?: string; staged?: boolean; mergeHeadFirstParent?: boolean }} params
|
||||
* @returns {ChangedLaneResult}
|
||||
*/
|
||||
/**
|
||||
* Classifies changed paths with optional package.json before/after contents.
|
||||
* @internal Shared repository-script contract.
|
||||
@@ -283,10 +288,6 @@ export function detectChangedLanesForPaths(params) {
|
||||
return detectChangedLanes(params.paths, { packageJsonChangeKind });
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ base: string; head?: string; includeWorktree?: boolean; cwd?: string; mergeHeadFirstParent?: boolean }} params
|
||||
* @returns {string[]}
|
||||
*/
|
||||
/**
|
||||
* Lists changed paths from git for a base/head comparison.
|
||||
*/
|
||||
|
||||
@@ -38,7 +38,8 @@ export function shouldDelegateChangedCheckToCrabbox(
|
||||
options?: { cwd?: string; result?: ChangedLaneResult; diffRefsReady?: boolean },
|
||||
): boolean;
|
||||
export function buildChangedCheckCrabboxArgs(argv?: string[], options?: { cwd?: string }): string[];
|
||||
export function shouldRunShrinkwrapGuard(paths: string[]): boolean;
|
||||
export function delegationFailedBeforeRunning(output: string): boolean;
|
||||
export function shouldRunNpmLockGuard(paths: string[]): boolean;
|
||||
export function shouldRunPromptSnapshotCheck(paths: string[]): boolean;
|
||||
export function shouldRunPromptSnapshotOwnerTest(paths: string[]): boolean;
|
||||
export function shouldRunControlUiI18nVerify(paths: string[]): boolean;
|
||||
@@ -50,7 +51,7 @@ export function shouldRunDeprecationHygieneChecks(paths: string[]): boolean;
|
||||
export function shouldRunCanvasA2uiNativeResourceCheck(paths: string[]): boolean;
|
||||
export function shouldRunAppcastOwnerTest(paths: string[]): boolean;
|
||||
export function shouldRunTestTempCreationReport(paths: string[]): boolean;
|
||||
export function createShrinkwrapGuardCommand(paths: string[]): ChangedCheckCommand | null;
|
||||
export function createNpmLockGuardCommand(paths: string[]): ChangedCheckCommand | null;
|
||||
export function createChangedCheckPlan(
|
||||
result: ChangedLaneResult,
|
||||
options?: ChangedCheckPlanOptions,
|
||||
|
||||
+205
-77
@@ -15,6 +15,7 @@ import { performance } from "node:perf_hooks";
|
||||
import {
|
||||
LIVE_DOCKER_AUTH_SHELL_TARGETS,
|
||||
detectChangedLanesForPaths,
|
||||
hasDeadcodeScannedSource,
|
||||
listChangedPathsFromGit,
|
||||
listStagedChangedPaths,
|
||||
} from "./changed-lanes.mjs";
|
||||
@@ -27,10 +28,11 @@ import {
|
||||
resolveLocalHeavyCheckEnv,
|
||||
} from "./lib/local-heavy-check-runtime.mjs";
|
||||
import { runManagedCommand } from "./lib/managed-child-process.mjs";
|
||||
import { listGeneratedExtensionAssetSources } from "./lib/static-extension-assets.mjs";
|
||||
import { createSparseTsgoSkipEnv } from "./lib/tsgo-sparse-guard.mjs";
|
||||
|
||||
const SHRINKWRAP_POLICY_PATH_RE =
|
||||
/^(?:npm-shrinkwrap\.json|package\.json|pnpm-lock\.yaml|pnpm-workspace\.yaml|scripts\/generate-npm-shrinkwrap\.mjs|extensions\/[^/]+\/(?:package\.json|npm-shrinkwrap\.json))$/u;
|
||||
const NPM_LOCK_POLICY_PATH_RE =
|
||||
/^(?:package\.json|pnpm-lock\.yaml|pnpm-workspace\.yaml|scripts\/generate-npm-package-lock\.mjs|(?:extensions|packages)\/[^/]+(?:\/.*)?\/package\.json)$/u;
|
||||
const PROMPT_SNAPSHOT_CHECK_PATH_RE =
|
||||
/^(?:scripts\/(?:generate-prompt-snapshots\.ts|prompt-snapshot-files\.ts|sync-codex-model-prompt-fixture\.ts)|test\/helpers\/agents\/(?:happy-path-prompt-snapshots|prompt-snapshot-paths)\.ts|test\/fixtures\/agents\/prompt-snapshots\/.+)$/u;
|
||||
const PROMPT_SNAPSHOT_OWNER_TEST_PATH_RE =
|
||||
@@ -73,13 +75,14 @@ const MACOS_APP_CI_PATH_RE =
|
||||
/^(?:apps\/(?:macos|macos-mlx-tts|shared|swabble)\/|Swabble\/|scripts\/(?:codesign-mac-app|create-dmg|notarize-mac-artifact|package-mac-app|package-mac-dist)\.sh$|scripts\/lib\/(?:plistbuddy|swift-toolchain)\.sh$|test\/scripts\/(?:codesign-mac-app|create-dmg|notarize-mac-artifact|package-mac-app|package-mac-dist)\.test\.ts$)/u;
|
||||
let corepackPnpmShimDir;
|
||||
let corepackPnpmShimCleanupRegistered = false;
|
||||
let shrinkwrapPackageDirsForChangedPaths;
|
||||
let cachedGeneratedExtensionAssetPaths;
|
||||
let npmLockPackageDirsForChangedPaths;
|
||||
|
||||
async function ensureChangedCheckRuntimeDependencies(paths) {
|
||||
if (!shouldRunShrinkwrapGuard(paths) || shrinkwrapPackageDirsForChangedPaths) {
|
||||
if (!shouldRunNpmLockGuard(paths) || npmLockPackageDirsForChangedPaths) {
|
||||
return;
|
||||
}
|
||||
({ shrinkwrapPackageDirsForChangedPaths } = await import("./generate-npm-shrinkwrap.mjs"));
|
||||
({ npmLockPackageDirsForChangedPaths } = await import("./generate-npm-package-lock.mjs"));
|
||||
}
|
||||
|
||||
// Imported consumers expect the synchronous planning API. Direct CLI execution
|
||||
@@ -217,18 +220,12 @@ function changedCheckDiffRefsReady({ base, head, cwd = process.cwd() }) {
|
||||
export function buildChangedCheckCrabboxArgs(argv = [], options = {}) {
|
||||
const delegatedArgv = buildDelegatedChangedCheckArgv(argv, options);
|
||||
return [
|
||||
"crabbox:run",
|
||||
"--",
|
||||
"--provider",
|
||||
"blacksmith-testbox",
|
||||
"--blacksmith-org",
|
||||
"openclaw",
|
||||
"--blacksmith-workflow",
|
||||
".github/workflows/ci-check-testbox.yml",
|
||||
"--blacksmith-job",
|
||||
"check",
|
||||
"--blacksmith-ref",
|
||||
"main",
|
||||
"scripts/crabbox-wrapper.mjs",
|
||||
"run",
|
||||
"--workload",
|
||||
"ci-fast",
|
||||
// Keep workload-routed calls provider-neutral. Blacksmith reads its workflow
|
||||
// defaults from .crabbox.yaml; cloud fallbacks must not receive its flags.
|
||||
"--idle-timeout",
|
||||
"90m",
|
||||
"--ttl",
|
||||
@@ -253,21 +250,15 @@ function buildDelegatedChangedCheckArgv(argv, options = {}) {
|
||||
return argv;
|
||||
}
|
||||
const stagedPaths = listStagedChangedPaths(options.cwd);
|
||||
const next = [];
|
||||
if (args.timed) {
|
||||
next.push("--timed");
|
||||
}
|
||||
const timedArgs = args.timed ? ["--timed"] : [];
|
||||
if (stagedPaths.length === 0) {
|
||||
next.push("--no-changes");
|
||||
return next;
|
||||
return [...timedArgs, "--no-changes"];
|
||||
}
|
||||
next.push("--base", "HEAD", "--head", "HEAD");
|
||||
next.push("--", ...stagedPaths);
|
||||
return next;
|
||||
return [...timedArgs, "--base", "HEAD", "--head", "HEAD", "--", ...stagedPaths];
|
||||
}
|
||||
|
||||
export function shouldRunShrinkwrapGuard(paths) {
|
||||
return paths.some((changedPath) => SHRINKWRAP_POLICY_PATH_RE.test(changedPath));
|
||||
export function shouldRunNpmLockGuard(paths) {
|
||||
return paths.some((changedPath) => NPM_LOCK_POLICY_PATH_RE.test(changedPath));
|
||||
}
|
||||
|
||||
export function shouldRunPromptSnapshotCheck(paths) {
|
||||
@@ -336,43 +327,101 @@ export function shouldRunTestTempCreationReport(paths) {
|
||||
);
|
||||
}
|
||||
|
||||
export function createShrinkwrapGuardCommand(paths) {
|
||||
if (!shouldRunShrinkwrapGuard(paths)) {
|
||||
export function createNpmLockGuardCommand(paths) {
|
||||
if (!shouldRunNpmLockGuard(paths)) {
|
||||
return null;
|
||||
}
|
||||
if (!shrinkwrapPackageDirsForChangedPaths) {
|
||||
throw new Error("changed-check shrinkwrap runtime dependencies were not loaded");
|
||||
if (!npmLockPackageDirsForChangedPaths) {
|
||||
throw new Error("changed-check npm-lock runtime dependencies were not loaded");
|
||||
}
|
||||
const packageDirs = shrinkwrapPackageDirsForChangedPaths(paths);
|
||||
const packageDirs = npmLockPackageDirsForChangedPaths(paths);
|
||||
if (packageDirs.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
name:
|
||||
packageDirs.length === 1
|
||||
? "npm shrinkwrap guard"
|
||||
: `npm shrinkwrap guard (${packageDirs.length} packages)`,
|
||||
? "npm package-lock guard"
|
||||
: `npm package-lock guard (${packageDirs.length} packages)`,
|
||||
bin: "node",
|
||||
args: [
|
||||
"scripts/generate-npm-shrinkwrap.mjs",
|
||||
"--check",
|
||||
"scripts/generate-npm-package-lock.mjs",
|
||||
...packageDirs.flatMap((packageDir) => ["--package-dir", packageDir]),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// Enough of the wrapper tail to hold its run summary; the rest is streamed, not kept.
|
||||
const DELEGATION_OUTPUT_TAIL_LIMIT = 64 * 1024;
|
||||
|
||||
/**
|
||||
* Signatures of a failure that happened before the remote command was dispatched:
|
||||
* the broker or its API was unreachable, or no lease was ever obtained.
|
||||
*/
|
||||
const BACKEND_UNAVAILABLE_SIGNATURES = [
|
||||
/request failed: \w+ "https?:\/\/[^"]*blacksmith[^"]*"/iu,
|
||||
/context deadline exceeded/iu,
|
||||
/(?:no such host|dial tcp|connection refused|network is unreachable)/iu,
|
||||
/failed to (?:acquire|create|warm|start)\b[^\n]*\b(?:lease|testbox)/iu,
|
||||
];
|
||||
|
||||
/**
|
||||
* Whether a failed delegation provably never ran our command.
|
||||
*
|
||||
* Fails closed on purpose. A missing final summary alone cannot prove the remote
|
||||
* never started — a wrapper that crashes or loses its output transport after
|
||||
* dispatch looks identical — so this requires a positive pre-dispatch signature
|
||||
* and treats everything else as a real failure. Getting this backwards is the
|
||||
* dangerous direction: some lanes (prompt snapshots) are Linux-only truth, so a
|
||||
* local rerun on macOS could turn an unknown or failing gate green.
|
||||
*
|
||||
* `command-exit` vetoes regardless: it only appears once the command reached the
|
||||
* box, so it is proof a verdict exists and must be propagated as-is.
|
||||
*/
|
||||
export function delegationFailedBeforeRunning(output) {
|
||||
if (/"errorKind"\s*:\s*"command-exit"/u.test(output)) {
|
||||
return false;
|
||||
}
|
||||
return BACKEND_UNAVAILABLE_SIGNATURES.some((signature) => signature.test(output));
|
||||
}
|
||||
|
||||
async function runChangedCheckViaCrabbox(argv = [], env = process.env) {
|
||||
console.error("[check:changed] delegating to Blacksmith Testbox via `pnpm crabbox:run`.");
|
||||
return await runManagedCommand({
|
||||
bin: "pnpm",
|
||||
console.error("[check:changed] delegating through Crabbox workload routing.");
|
||||
let tail = "";
|
||||
const exitCode = await runManagedCommand({
|
||||
bin: "node",
|
||||
args: buildChangedCheckCrabboxArgs(argv),
|
||||
env,
|
||||
stdio: ["inherit", "pipe", "pipe"],
|
||||
onReady: (child) => {
|
||||
for (const stream of [child.stdout, child.stderr]) {
|
||||
stream?.on("data", (chunk) => {
|
||||
tail = (tail + chunk).slice(-DELEGATION_OUTPUT_TAIL_LIMIT);
|
||||
// Inherited stdio used to get OS backpressure for free. Piping means we
|
||||
// have to reapply it, or a verbose delegated run buffers its whole
|
||||
// output in this process when stderr is an async pipe (typical in CI).
|
||||
if (!process.stderr.write(chunk)) {
|
||||
stream.pause();
|
||||
process.stderr.once("drain", () => stream.resume());
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
return {
|
||||
exitCode,
|
||||
backendUnavailable: exitCode !== 0 && delegationFailedBeforeRunning(tail),
|
||||
};
|
||||
}
|
||||
|
||||
export function createChangedCheckPlan(result, options = {}) {
|
||||
const commands = [];
|
||||
const baseEnv = createChangedCheckChildEnv(options.env ?? process.env);
|
||||
const generatedExtensionAssetPaths = result.paths.some((changedPath) =>
|
||||
LINTABLE_EXTENSION_PATH_RE.test(changedPath),
|
||||
)
|
||||
? (cachedGeneratedExtensionAssetPaths ??= new Set(listGeneratedExtensionAssetSources()))
|
||||
: new Set();
|
||||
const add = (name, args, env) => {
|
||||
if (!commands.some((command) => command.name === name && sameArgs(command.args, args))) {
|
||||
commands.push({ name, args, ...(env ? { env } : {}) });
|
||||
@@ -389,6 +438,41 @@ export function createChangedCheckPlan(result, options = {}) {
|
||||
};
|
||||
const addTypecheck = (name, args) => add(name, args, createSparseTsgoSkipEnv(baseEnv));
|
||||
const addLint = (name, args) => add(name, args, baseEnv);
|
||||
const addTargetedLint = (
|
||||
createCommand,
|
||||
lintablePathRe,
|
||||
fallbackName,
|
||||
fallbackArgs,
|
||||
ignoredPaths,
|
||||
) => {
|
||||
const candidatePaths = ignoredPaths
|
||||
? result.paths.filter((changedPath) => !ignoredPaths.has(changedPath))
|
||||
: result.paths;
|
||||
const targets = candidatePaths.filter((changedPath) => lintablePathRe.test(changedPath));
|
||||
const otherPaths = candidatePaths.filter((changedPath) => !lintablePathRe.test(changedPath));
|
||||
const targetedCommands = [];
|
||||
|
||||
for (let offset = 0; offset < targets.length; offset += TARGETED_LINT_PATH_LIMIT) {
|
||||
const command = createCommand(
|
||||
[...otherPaths, ...targets.slice(offset, offset + TARGETED_LINT_PATH_LIMIT)],
|
||||
baseEnv,
|
||||
);
|
||||
if (!command) {
|
||||
addLint(fallbackName, fallbackArgs);
|
||||
return false;
|
||||
}
|
||||
targetedCommands.push(command);
|
||||
}
|
||||
|
||||
if (targetedCommands.length === 0) {
|
||||
addLint(fallbackName, fallbackArgs);
|
||||
return false;
|
||||
}
|
||||
for (const command of targetedCommands) {
|
||||
addCommand(command.name, command.bin, command.args, command.env);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
const addTestTempCreationReport = () => {
|
||||
if (!shouldRunTestTempCreationReport(result.paths)) {
|
||||
return;
|
||||
@@ -407,6 +491,20 @@ export function createChangedCheckPlan(result, options = {}) {
|
||||
};
|
||||
|
||||
add("conflict markers", ["check:no-conflict-markers"]);
|
||||
if (
|
||||
result.paths.some((filePath) =>
|
||||
/^(?:src\/|packages\/|extensions\/|config\/env-var-count-budget\.txt$|scripts\/check-env-var-count\.mjs$)/u.test(
|
||||
filePath,
|
||||
),
|
||||
)
|
||||
) {
|
||||
add("environment variable count ratchet", [
|
||||
"check:env-var-count",
|
||||
...(options.staged ? ["--staged"] : []),
|
||||
"--base",
|
||||
options.staged ? "HEAD" : (options.base ?? "origin/main"),
|
||||
]);
|
||||
}
|
||||
if (
|
||||
result.paths.some((filePath) =>
|
||||
/^(?:src\/|ui\/src\/|packages\/|extensions\/|\.oxlintrc\.json$|config\/max-lines-baseline\.txt$|scripts\/check-max-lines-ratchet\.mjs$)/u.test(
|
||||
@@ -434,12 +532,12 @@ export function createChangedCheckPlan(result, options = {}) {
|
||||
...result.paths,
|
||||
]);
|
||||
}
|
||||
const shrinkwrapGuardCommand = createShrinkwrapGuardCommand(result.paths);
|
||||
if (shrinkwrapGuardCommand) {
|
||||
const npmLockGuardCommand = createNpmLockGuardCommand(result.paths);
|
||||
if (npmLockGuardCommand) {
|
||||
addCommand(
|
||||
shrinkwrapGuardCommand.name,
|
||||
shrinkwrapGuardCommand.bin,
|
||||
shrinkwrapGuardCommand.args,
|
||||
npmLockGuardCommand.name,
|
||||
npmLockGuardCommand.bin,
|
||||
npmLockGuardCommand.args,
|
||||
baseEnv,
|
||||
);
|
||||
}
|
||||
@@ -461,6 +559,9 @@ export function createChangedCheckPlan(result, options = {}) {
|
||||
baseEnv,
|
||||
);
|
||||
}
|
||||
if (result.lanes.all || result.lanes.bundledChannelConfigMetadata) {
|
||||
add("bundled channel config metadata", ["check:bundled-channel-config-metadata"]);
|
||||
}
|
||||
if (shouldRunSqliteSessionSchemaBaselineCheck(result.paths)) {
|
||||
add("SQLite sessions/transcripts schema baseline", ["sqlite:sessions-schema:check"]);
|
||||
}
|
||||
@@ -493,6 +594,17 @@ export function createChangedCheckPlan(result, options = {}) {
|
||||
);
|
||||
}
|
||||
add("package patch guard", ["deps:patches:check"]);
|
||||
if (
|
||||
hasDeadcodeScannedSource(result.paths) &&
|
||||
!isTruthyEnvFlag(baseEnv.OPENCLAW_CHECK_CHANGED_SKIP_DEADCODE)
|
||||
) {
|
||||
addCommand(
|
||||
"dead export scan (skip with OPENCLAW_CHECK_CHANGED_SKIP_DEADCODE=1)",
|
||||
"node",
|
||||
["scripts/check-deadcode-exports.mjs"],
|
||||
baseEnv,
|
||||
);
|
||||
}
|
||||
|
||||
if (result.docsOnly) {
|
||||
return {
|
||||
@@ -571,17 +683,9 @@ export function createChangedCheckPlan(result, options = {}) {
|
||||
}
|
||||
|
||||
if (lanes.core || lanes.coreTests || lanes.ui) {
|
||||
const coreLintCommand = createTargetedCoreLintCommand(result.paths, baseEnv);
|
||||
if (coreLintCommand) {
|
||||
addCommand(
|
||||
coreLintCommand.name,
|
||||
coreLintCommand.bin,
|
||||
coreLintCommand.args,
|
||||
coreLintCommand.env,
|
||||
);
|
||||
} else {
|
||||
addLint("lint core", ["lint:core"]);
|
||||
}
|
||||
addTargetedLint(createTargetedCoreLintCommand, LINTABLE_CORE_PATH_RE, "lint core", [
|
||||
"lint:core",
|
||||
]);
|
||||
}
|
||||
if (
|
||||
lanes.liveDockerTooling &&
|
||||
@@ -591,31 +695,33 @@ export function createChangedCheckPlan(result, options = {}) {
|
||||
addLint("lint core", ["lint:core"]);
|
||||
}
|
||||
if (lanes.extensions || lanes.extensionTests) {
|
||||
const extensionLintCommand = createTargetedExtensionLintCommand(result.paths, baseEnv);
|
||||
if (extensionLintCommand) {
|
||||
addCommand(
|
||||
extensionLintCommand.name,
|
||||
extensionLintCommand.bin,
|
||||
extensionLintCommand.args,
|
||||
extensionLintCommand.env,
|
||||
// Generated plugin outputs have their own asset-integrity gate and are
|
||||
// intentionally ignored by oxlint; manifests still need full-lane fallback.
|
||||
if (
|
||||
!result.paths.some((changedPath) => generatedExtensionAssetPaths.has(changedPath)) ||
|
||||
result.paths.some(
|
||||
(changedPath) =>
|
||||
getChangedPathFacts(changedPath).surface === "extension" &&
|
||||
!generatedExtensionAssetPaths.has(changedPath),
|
||||
)
|
||||
) {
|
||||
addTargetedLint(
|
||||
createTargetedExtensionLintCommand,
|
||||
LINTABLE_EXTENSION_PATH_RE,
|
||||
"lint extensions",
|
||||
["lint:extensions"],
|
||||
generatedExtensionAssetPaths,
|
||||
);
|
||||
} else {
|
||||
addLint("lint extensions", ["lint:extensions"]);
|
||||
}
|
||||
}
|
||||
if (lanes.tooling || lanes.liveDockerTooling) {
|
||||
const scriptLintCommand = createTargetedScriptLintCommand(result.paths, baseEnv);
|
||||
if (scriptLintCommand) {
|
||||
if (
|
||||
addTargetedLint(createTargetedScriptLintCommand, LINTABLE_SCRIPT_PATH_RE, "lint scripts", [
|
||||
"lint:scripts",
|
||||
])
|
||||
) {
|
||||
addLint("lint docker-e2e", ["lint:docker-e2e"]);
|
||||
addLint("raw HTTP/2 import guard", ["lint:tmp:no-raw-http2-imports"]);
|
||||
addCommand(
|
||||
scriptLintCommand.name,
|
||||
scriptLintCommand.bin,
|
||||
scriptLintCommand.args,
|
||||
scriptLintCommand.env,
|
||||
);
|
||||
} else {
|
||||
addLint("lint scripts", ["lint:scripts"]);
|
||||
}
|
||||
}
|
||||
if (lanes.apps && shouldSkipAppLintForMissingSwiftlint({ ...options, env: baseEnv })) {
|
||||
@@ -722,6 +828,9 @@ function createTargetedOxlintCommand({
|
||||
paths.some(
|
||||
(changedPath) =>
|
||||
!lintablePathRe.test(changedPath) &&
|
||||
!LINTABLE_CORE_PATH_RE.test(changedPath) &&
|
||||
!LINTABLE_EXTENSION_PATH_RE.test(changedPath) &&
|
||||
!LINTABLE_SCRIPT_PATH_RE.test(changedPath) &&
|
||||
!neutralPathRe.test(changedPath) &&
|
||||
!MARKDOWN_LINT_OPTIMIZATION_NEUTRAL_PATH_RE.test(changedPath),
|
||||
)
|
||||
@@ -999,7 +1108,13 @@ if (isDirectRun()) {
|
||||
if (!shouldDelegateChangedCheckToCrabbox(argv, process.env)) {
|
||||
throw error;
|
||||
}
|
||||
process.exitCode = await runChangedCheckViaCrabbox(argv, process.env);
|
||||
// No local fallback here: this path exists because the checkout cannot
|
||||
// resolve the diff refs itself, so there is nothing local to run.
|
||||
const delegated = await runChangedCheckViaCrabbox(argv, process.env);
|
||||
if (delegated.backendUnavailable) {
|
||||
throw error;
|
||||
}
|
||||
process.exitCode = delegated.exitCode;
|
||||
}
|
||||
if (paths) {
|
||||
const result = detectChangedLanesForPaths({
|
||||
@@ -1021,7 +1136,20 @@ if (isDirectRun()) {
|
||||
: undefined,
|
||||
})
|
||||
) {
|
||||
process.exitCode = await runChangedCheckViaCrabbox(argv, process.env);
|
||||
const delegated = await runChangedCheckViaCrabbox(argv, process.env);
|
||||
if (delegated.backendUnavailable) {
|
||||
// Say this loudly: the proof below is local, so whoever reads the run
|
||||
// knows which machine produced it and that Linux-only lanes are unproven.
|
||||
console.error(
|
||||
"[check:changed] Blacksmith never ran the checks (no run summary). Falling back to local execution; note this in the proof summary.",
|
||||
);
|
||||
}
|
||||
process.exitCode = delegated.backendUnavailable
|
||||
? await runChangedCheck(result, {
|
||||
...args,
|
||||
explicitPaths: args.paths.length > 0,
|
||||
})
|
||||
: delegated.exitCode;
|
||||
} else {
|
||||
process.exitCode = await runChangedCheck(result, {
|
||||
...args,
|
||||
|
||||
@@ -57,6 +57,141 @@ const checks: Array<{ file: string; snippets: string[] }> = [
|
||||
file: "v2/Account.ts",
|
||||
snippets: ['type: "apiKey"', 'type: "chatgpt"', 'type: "amazonBedrock"'],
|
||||
},
|
||||
{
|
||||
file: "v2/AppSummary.ts",
|
||||
snippets: [
|
||||
"description: string | null",
|
||||
"installUrl: string | null",
|
||||
"category: string | null",
|
||||
],
|
||||
},
|
||||
{
|
||||
file: "v2/AppsInstalledParams.ts",
|
||||
snippets: ["threadId?: string | null", "forceRefresh?: boolean"],
|
||||
},
|
||||
{
|
||||
file: "v2/AppsInstalledResponse.ts",
|
||||
snippets: ["apps: Array<InstalledApp>"],
|
||||
},
|
||||
{
|
||||
file: "v2/AppsReadParams.ts",
|
||||
snippets: ["appIds: Array<string>", "includeTools?: boolean"],
|
||||
},
|
||||
{
|
||||
file: "v2/AppsReadResponse.ts",
|
||||
snippets: ["apps: Array<ConnectorMetadata>", "missingAppIds: Array<string>"],
|
||||
},
|
||||
{
|
||||
file: "v2/CommandExecParams.ts",
|
||||
snippets: [
|
||||
"command: Array<string>",
|
||||
"outputBytesCap?: number | null",
|
||||
"timeoutMs?: number | null",
|
||||
"env?: { [key in string]?: string | null } | null",
|
||||
],
|
||||
},
|
||||
{
|
||||
file: "v2/CommandExecResponse.ts",
|
||||
snippets: ["exitCode: number", "stdout: string", "stderr: string"],
|
||||
},
|
||||
{
|
||||
file: "v2/ConfigBatchWriteParams.ts",
|
||||
snippets: [
|
||||
"edits: Array<ConfigEdit>",
|
||||
"filePath?: string | null",
|
||||
"expectedVersion?: string | null",
|
||||
"reloadUserConfig?: boolean",
|
||||
],
|
||||
},
|
||||
{
|
||||
file: "v2/ConfigEdit.ts",
|
||||
snippets: ["keyPath: string", "value: JsonValue", "mergeStrategy: MergeStrategy"],
|
||||
},
|
||||
{
|
||||
file: "v2/ConfigValueWriteParams.ts",
|
||||
snippets: [
|
||||
"keyPath: string",
|
||||
"value: JsonValue",
|
||||
"mergeStrategy: MergeStrategy",
|
||||
"filePath?: string | null",
|
||||
"expectedVersion?: string | null",
|
||||
],
|
||||
},
|
||||
{
|
||||
file: "v2/ConfigWriteResponse.ts",
|
||||
snippets: [
|
||||
"status: WriteStatus",
|
||||
"version: string",
|
||||
"filePath: AbsolutePathBuf",
|
||||
"overriddenMetadata: OverriddenMetadata | null",
|
||||
],
|
||||
},
|
||||
{
|
||||
file: "v2/InstalledApp.ts",
|
||||
snippets: ["runtimeName: string | null", "enabled: boolean", "callable: boolean"],
|
||||
},
|
||||
{
|
||||
file: "v2/MarketplaceLoadErrorInfo.ts",
|
||||
snippets: ["marketplacePath: AbsolutePathBuf", "message: string"],
|
||||
},
|
||||
{
|
||||
file: "v2/MergeStrategy.ts",
|
||||
snippets: ['"replace"', '"upsert"'],
|
||||
},
|
||||
{
|
||||
file: "v2/OverriddenMetadata.ts",
|
||||
snippets: [
|
||||
"message: string",
|
||||
"overridingLayer: ConfigLayerMetadata",
|
||||
"effectiveValue: JsonValue",
|
||||
],
|
||||
},
|
||||
{
|
||||
file: "v2/PluginSummary.ts",
|
||||
snippets: ["remotePluginId: string | null"],
|
||||
},
|
||||
{
|
||||
file: "v2/PluginListParams.ts",
|
||||
snippets: ["forceRefetch?: boolean"],
|
||||
},
|
||||
{
|
||||
file: "v2/PluginInstalledParams.ts",
|
||||
snippets: [
|
||||
"cwds?: Array<AbsolutePathBuf> | null",
|
||||
"installSuggestionPluginNames?: Array<string> | null",
|
||||
],
|
||||
},
|
||||
{
|
||||
file: "v2/PluginInstalledResponse.ts",
|
||||
snippets: [
|
||||
"marketplaces: Array<PluginMarketplaceEntry>",
|
||||
"marketplaceLoadErrors: Array<MarketplaceLoadErrorInfo>",
|
||||
],
|
||||
},
|
||||
{
|
||||
file: "v2/PluginListResponse.ts",
|
||||
snippets: [
|
||||
"marketplaces: Array<PluginMarketplaceEntry>",
|
||||
"marketplaceLoadErrors: Array<MarketplaceLoadErrorInfo>",
|
||||
"featuredPluginIds: Array<string>",
|
||||
],
|
||||
},
|
||||
{
|
||||
file: "v2/PluginReadParams.ts",
|
||||
snippets: ["pluginName: string"],
|
||||
},
|
||||
{
|
||||
file: "v2/PluginReadResponse.ts",
|
||||
snippets: ["plugin: PluginDetail"],
|
||||
},
|
||||
{
|
||||
file: "v2/PluginInstallParams.ts",
|
||||
snippets: ["pluginName: string"],
|
||||
},
|
||||
{
|
||||
file: "v2/PluginInstallResponse.ts",
|
||||
snippets: ["appsNeedingAuth: Array<AppSummary>"],
|
||||
},
|
||||
{
|
||||
file: "v2/ThreadStartParams.ts",
|
||||
snippets: [
|
||||
@@ -69,9 +204,13 @@ const checks: Array<{ file: string; snippets: string[] }> = [
|
||||
file: "v2/TurnStartParams.ts",
|
||||
snippets: ["permissions?: string | null", "serviceTier?: string | null"],
|
||||
},
|
||||
{
|
||||
file: "v2/WriteStatus.ts",
|
||||
snippets: ['"ok"', '"okOverridden"'],
|
||||
},
|
||||
{
|
||||
file: "ReviewDecision.ts",
|
||||
snippets: ['"approved"', '"approved_for_session"', '"denied"', '"abort"'],
|
||||
snippets: ['"approved"', '"approved_for_session"', "denied: { rejection: string }", '"abort"'],
|
||||
},
|
||||
{
|
||||
file: "v2/PlanDeltaNotification.ts",
|
||||
@@ -142,9 +281,12 @@ async function checkMaintainedProtocolTypes(sourceRoot: string): Promise<void> {
|
||||
const probe = `
|
||||
import type {
|
||||
CodexAppServerRequestParams,
|
||||
CodexAppServerRequestResult,
|
||||
CodexConfigEdit,
|
||||
CodexDynamicToolSpec,
|
||||
CodexDynamicToolCallParams,
|
||||
CodexErrorNotification,
|
||||
CodexGetAccountResponse,
|
||||
CodexModelListResponse,
|
||||
CodexThreadForkParams,
|
||||
CodexThreadForkResponse,
|
||||
@@ -154,11 +296,37 @@ import type {
|
||||
CodexThreadStartResponse,
|
||||
CodexTurnEnvironmentParams,
|
||||
CodexTurnStartParams,
|
||||
v2,
|
||||
} from ${JSON.stringify(protocolImport)};
|
||||
import type { AppSummary } from ${JSON.stringify(generatedImport("v2/AppSummary.ts"))};
|
||||
import type { AppsInstalledParams } from ${JSON.stringify(generatedImport("v2/AppsInstalledParams.ts"))};
|
||||
import type { AppsInstalledResponse } from ${JSON.stringify(generatedImport("v2/AppsInstalledResponse.ts"))};
|
||||
import type { AppsListParams } from ${JSON.stringify(generatedImport("v2/AppsListParams.ts"))};
|
||||
import type { AppsListResponse } from ${JSON.stringify(generatedImport("v2/AppsListResponse.ts"))};
|
||||
import type { AppsReadParams } from ${JSON.stringify(generatedImport("v2/AppsReadParams.ts"))};
|
||||
import type { AppsReadResponse } from ${JSON.stringify(generatedImport("v2/AppsReadResponse.ts"))};
|
||||
import type { CommandExecParams } from ${JSON.stringify(generatedImport("v2/CommandExecParams.ts"))};
|
||||
import type { CommandExecResponse } from ${JSON.stringify(generatedImport("v2/CommandExecResponse.ts"))};
|
||||
import type { ConfigBatchWriteParams } from ${JSON.stringify(generatedImport("v2/ConfigBatchWriteParams.ts"))};
|
||||
import type { ConfigEdit } from ${JSON.stringify(generatedImport("v2/ConfigEdit.ts"))};
|
||||
import type { ConfigValueWriteParams } from ${JSON.stringify(generatedImport("v2/ConfigValueWriteParams.ts"))};
|
||||
import type { ConfigWriteResponse } from ${JSON.stringify(generatedImport("v2/ConfigWriteResponse.ts"))};
|
||||
import type { DynamicToolCallParams } from ${JSON.stringify(generatedImport("v2/DynamicToolCallParams.ts"))};
|
||||
import type { DynamicToolSpec } from ${JSON.stringify(generatedImport("v2/DynamicToolSpec.ts"))};
|
||||
import type { ErrorNotification } from ${JSON.stringify(generatedImport("v2/ErrorNotification.ts"))};
|
||||
import type { GetAccountResponse } from ${JSON.stringify(generatedImport("v2/GetAccountResponse.ts"))};
|
||||
import type { MarketplaceLoadErrorInfo } from ${JSON.stringify(generatedImport("v2/MarketplaceLoadErrorInfo.ts"))};
|
||||
import type { ModelListResponse } from ${JSON.stringify(generatedImport("v2/ModelListResponse.ts"))};
|
||||
import type { PluginInstalledParams } from ${JSON.stringify(generatedImport("v2/PluginInstalledParams.ts"))};
|
||||
import type { PluginInstalledResponse } from ${JSON.stringify(generatedImport("v2/PluginInstalledResponse.ts"))};
|
||||
import type { PluginInstallParams } from ${JSON.stringify(generatedImport("v2/PluginInstallParams.ts"))};
|
||||
import type { PluginInstallResponse } from ${JSON.stringify(generatedImport("v2/PluginInstallResponse.ts"))};
|
||||
import type { PluginListParams } from ${JSON.stringify(generatedImport("v2/PluginListParams.ts"))};
|
||||
import type { PluginListResponse } from ${JSON.stringify(generatedImport("v2/PluginListResponse.ts"))};
|
||||
import type { PluginReadParams } from ${JSON.stringify(generatedImport("v2/PluginReadParams.ts"))};
|
||||
import type { PluginReadResponse } from ${JSON.stringify(generatedImport("v2/PluginReadResponse.ts"))};
|
||||
import type { ThreadDeleteParams } from ${JSON.stringify(generatedImport("v2/ThreadDeleteParams.ts"))};
|
||||
import type { ThreadDeleteResponse } from ${JSON.stringify(generatedImport("v2/ThreadDeleteResponse.ts"))};
|
||||
import type { ThreadForkParams } from ${JSON.stringify(generatedImport("v2/ThreadForkParams.ts"))};
|
||||
import type { ThreadForkResponse } from ${JSON.stringify(generatedImport("v2/ThreadForkResponse.ts"))};
|
||||
import type { ThreadResumeParams } from ${JSON.stringify(generatedImport("v2/ThreadResumeParams.ts"))};
|
||||
@@ -169,6 +337,33 @@ import type { TurnEnvironmentParams } from ${JSON.stringify(generatedImport("v2/
|
||||
import type { TurnInterruptParams } from ${JSON.stringify(generatedImport("v2/TurnInterruptParams.ts"))};
|
||||
import type { TurnStartParams } from ${JSON.stringify(generatedImport("v2/TurnStartParams.ts"))};
|
||||
|
||||
declare const openClawAppsInstalledParams: CodexAppServerRequestParams<"app/installed">;
|
||||
const generatedAppsInstalledParams: AppsInstalledParams = openClawAppsInstalledParams;
|
||||
declare const openClawAppsListParams: CodexAppServerRequestParams<"app/list">;
|
||||
const generatedAppsListParams: AppsListParams = openClawAppsListParams;
|
||||
declare const openClawAppsReadParams: CodexAppServerRequestParams<"app/read">;
|
||||
const generatedAppsReadParams: AppsReadParams = openClawAppsReadParams;
|
||||
declare const openClawAppSummary: v2.AppSummary;
|
||||
const generatedAppSummary: AppSummary = openClawAppSummary;
|
||||
declare const openClawCommandExecParams: CodexAppServerRequestParams<"command/exec">;
|
||||
const generatedCommandExecParams: CommandExecParams = openClawCommandExecParams;
|
||||
declare const generatedNullableCommandExecParams: CommandExecParams;
|
||||
const openClawNullableCommandExecParams: CodexAppServerRequestParams<"command/exec"> =
|
||||
generatedNullableCommandExecParams;
|
||||
declare const openClawConfigBatchWriteParams: CodexAppServerRequestParams<"config/batchWrite">;
|
||||
const generatedConfigBatchWriteParams: ConfigBatchWriteParams = openClawConfigBatchWriteParams;
|
||||
declare const openClawConfigEdit: CodexConfigEdit;
|
||||
const generatedConfigEdit: ConfigEdit = openClawConfigEdit;
|
||||
declare const openClawConfigValueWriteParams: CodexAppServerRequestParams<"config/value/write">;
|
||||
const generatedConfigValueWriteParams: ConfigValueWriteParams = openClawConfigValueWriteParams;
|
||||
declare const openClawPluginInstalledParams: CodexAppServerRequestParams<"plugin/installed">;
|
||||
const generatedPluginInstalledParams: PluginInstalledParams = openClawPluginInstalledParams;
|
||||
declare const openClawPluginInstallParams: CodexAppServerRequestParams<"plugin/install">;
|
||||
const generatedPluginInstallParams: PluginInstallParams = openClawPluginInstallParams;
|
||||
declare const openClawPluginListParams: CodexAppServerRequestParams<"plugin/list">;
|
||||
const generatedPluginListParams: PluginListParams = openClawPluginListParams;
|
||||
declare const openClawPluginReadParams: CodexAppServerRequestParams<"plugin/read">;
|
||||
const generatedPluginReadParams: PluginReadParams = openClawPluginReadParams;
|
||||
declare const openClawDynamicToolSpec: CodexDynamicToolSpec;
|
||||
const generatedDynamicToolSpec: DynamicToolSpec = openClawDynamicToolSpec;
|
||||
declare const openClawTurnEnvironmentParams: CodexTurnEnvironmentParams;
|
||||
@@ -179,18 +374,69 @@ declare const openClawThreadResumeParams: CodexThreadResumeParams;
|
||||
const generatedThreadResumeParams: ThreadResumeParams = openClawThreadResumeParams;
|
||||
declare const openClawThreadForkParams: CodexThreadForkParams;
|
||||
const generatedThreadForkParams: ThreadForkParams = openClawThreadForkParams;
|
||||
declare const openClawThreadDeleteParams: CodexAppServerRequestParams<"thread/delete">;
|
||||
const generatedThreadDeleteParams: ThreadDeleteParams = openClawThreadDeleteParams;
|
||||
declare const openClawTurnInterruptParams: CodexAppServerRequestParams<"turn/interrupt">;
|
||||
const generatedTurnInterruptParams: TurnInterruptParams = openClawTurnInterruptParams;
|
||||
declare const openClawTurnStartParams: CodexTurnStartParams;
|
||||
const generatedTurnStartParams: TurnStartParams = openClawTurnStartParams;
|
||||
|
||||
declare const generatedAppsInstalledResponse: AppsInstalledResponse;
|
||||
const openClawAppsInstalledResponse: CodexAppServerRequestResult<"app/installed"> =
|
||||
generatedAppsInstalledResponse;
|
||||
declare const generatedAppsListResponse: AppsListResponse;
|
||||
const openClawAppsListResponse: CodexAppServerRequestResult<"app/list"> =
|
||||
generatedAppsListResponse;
|
||||
declare const generatedAppsReadResponse: AppsReadResponse;
|
||||
const openClawAppsReadResponse: CodexAppServerRequestResult<"app/read"> =
|
||||
generatedAppsReadResponse;
|
||||
declare const generatedAppSummaryResponse: AppSummary;
|
||||
const openClawAppSummaryResponse: v2.AppSummary = generatedAppSummaryResponse;
|
||||
declare const generatedCommandExecResponse: CommandExecResponse;
|
||||
const openClawCommandExecResponse: CodexAppServerRequestResult<"command/exec"> =
|
||||
generatedCommandExecResponse;
|
||||
declare const generatedConfigWriteResponse: ConfigWriteResponse;
|
||||
const openClawConfigBatchWriteResponse: CodexAppServerRequestResult<"config/batchWrite"> =
|
||||
generatedConfigWriteResponse;
|
||||
const openClawConfigValueWriteResponse: CodexAppServerRequestResult<"config/value/write"> =
|
||||
generatedConfigWriteResponse;
|
||||
const generatedExactConfigBatchWriteResponse: ConfigWriteResponse =
|
||||
openClawConfigBatchWriteResponse;
|
||||
const generatedExactConfigValueWriteResponse: ConfigWriteResponse =
|
||||
openClawConfigValueWriteResponse;
|
||||
declare const generatedPluginInstalledResponse: PluginInstalledResponse;
|
||||
const openClawPluginInstalledResponse: CodexAppServerRequestResult<"plugin/installed"> =
|
||||
generatedPluginInstalledResponse;
|
||||
const generatedPluginInstalledMarketplaceLoadErrors: MarketplaceLoadErrorInfo[] =
|
||||
openClawPluginInstalledResponse.marketplaceLoadErrors;
|
||||
type InstalledPluginResponseHasNoFeaturedCatalog =
|
||||
"featuredPluginIds" extends keyof v2.PluginInstalledResponse ? never : true;
|
||||
const installedPluginResponseHasNoFeaturedCatalog: InstalledPluginResponseHasNoFeaturedCatalog =
|
||||
true;
|
||||
declare const generatedPluginInstallResponse: PluginInstallResponse;
|
||||
const openClawPluginInstallResponse: CodexAppServerRequestResult<"plugin/install"> =
|
||||
generatedPluginInstallResponse;
|
||||
declare const generatedPluginListResponse: PluginListResponse;
|
||||
const openClawPluginListResponse: CodexAppServerRequestResult<"plugin/list"> =
|
||||
generatedPluginListResponse;
|
||||
const generatedPluginListMarketplaceLoadErrors: MarketplaceLoadErrorInfo[] =
|
||||
openClawPluginListResponse.marketplaceLoadErrors;
|
||||
const generatedPluginListFeaturedPluginIds: string[] = openClawPluginListResponse.featuredPluginIds;
|
||||
declare const generatedPluginReadResponse: PluginReadResponse;
|
||||
const openClawPluginReadResponse: CodexAppServerRequestResult<"plugin/read"> =
|
||||
generatedPluginReadResponse;
|
||||
declare const generatedDynamicToolCallParams: Omit<DynamicToolCallParams, "arguments">;
|
||||
const openClawDynamicToolCallParams: Omit<CodexDynamicToolCallParams, "arguments"> =
|
||||
generatedDynamicToolCallParams;
|
||||
declare const generatedErrorNotification: ErrorNotification;
|
||||
const openClawErrorNotification: CodexErrorNotification = generatedErrorNotification;
|
||||
declare const generatedGetAccountResponse: GetAccountResponse;
|
||||
const openClawGetAccountResponse: CodexGetAccountResponse = generatedGetAccountResponse;
|
||||
declare const generatedModelListResponse: ModelListResponse;
|
||||
const openClawModelListResponse: CodexModelListResponse = generatedModelListResponse;
|
||||
declare const generatedThreadDeleteResponse: ThreadDeleteResponse;
|
||||
const openClawThreadDeleteResponse: CodexAppServerRequestResult<"thread/delete"> =
|
||||
generatedThreadDeleteResponse;
|
||||
|
||||
// Thread and turn bodies are normalized behind checked-in JSON schemas. Their
|
||||
// raw generated shapes must not be confused with the projector-facing types.
|
||||
|
||||
@@ -33,31 +33,47 @@ export type ControlUiPerformanceBudgets = {
|
||||
largestCssGzipBytes: number;
|
||||
};
|
||||
|
||||
export type ControlUiStartupBudgetBaseline = {
|
||||
startupJsGzipBytes: number;
|
||||
reason: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type ControlUiPerformanceBudgetViolation = {
|
||||
metric: string;
|
||||
actual: number;
|
||||
limit: number;
|
||||
unit: "count" | "bytes";
|
||||
baseline?: number;
|
||||
tolerance?: number;
|
||||
};
|
||||
|
||||
export const CONTROL_UI_PERFORMANCE_BUDGETS: Readonly<ControlUiPerformanceBudgets>;
|
||||
export const CONTROL_UI_STARTUP_JS_GZIP_TOLERANCE_BYTES: 1024;
|
||||
export function extractControlUiStartupAssetPaths(html: string): string[];
|
||||
export function collectControlUiPerformanceMetrics(distDir: string): ControlUiPerformanceMetrics;
|
||||
export function evaluateControlUiPerformanceBudgets(
|
||||
metrics: ControlUiPerformanceMetrics,
|
||||
budgets?: Readonly<ControlUiPerformanceBudgets>,
|
||||
startupBudgetBaseline?: Readonly<ControlUiStartupBudgetBaseline>,
|
||||
startupJsTolerance?: number,
|
||||
): ControlUiPerformanceBudgetViolation[];
|
||||
export function formatControlUiPerformanceBytes(bytes: number): string;
|
||||
export function formatControlUiPerformanceReport(
|
||||
metrics: ControlUiPerformanceMetrics,
|
||||
budgets?: Readonly<ControlUiPerformanceBudgets>,
|
||||
startupBudgetBaseline?: Readonly<ControlUiStartupBudgetBaseline>,
|
||||
startupJsTolerance?: number,
|
||||
): string;
|
||||
export function runControlUiPerformanceCheck(
|
||||
distDir: string,
|
||||
budgets?: Readonly<ControlUiPerformanceBudgets>,
|
||||
baselinePath?: string,
|
||||
): {
|
||||
metrics: ControlUiPerformanceMetrics;
|
||||
budgets: Readonly<ControlUiPerformanceBudgets>;
|
||||
startupBudgetBaseline: ControlUiStartupBudgetBaseline;
|
||||
startupJsTolerance: number;
|
||||
violations: ControlUiPerformanceBudgetViolation[];
|
||||
report: string;
|
||||
};
|
||||
|
||||
@@ -6,16 +6,27 @@ import process from "node:process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const KIB = 1024;
|
||||
const STARTUP_JS_BASELINE_RATCHET_BYTES = 4096;
|
||||
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||
const DEFAULT_STARTUP_BUDGET_BASELINE_PATH = path.resolve(
|
||||
SCRIPT_DIR,
|
||||
"../config/control-ui-startup-budget-baseline.json",
|
||||
);
|
||||
|
||||
// This absorbs measured local-to-Linux gzip variance, but landed changes can
|
||||
// still consume the tolerance. Local zlib emits smaller streams than CI's Linux
|
||||
// builder, so baseline updates must use CI bytes via --startup-js-bytes. The
|
||||
// fixed JS ceiling bounds cumulative creep.
|
||||
export const CONTROL_UI_STARTUP_JS_GZIP_TOLERANCE_BYTES = 1024;
|
||||
|
||||
// Small, explicit headroom over the optimized baseline. Budget changes should
|
||||
// accompany an intentional loading or chunking decision.
|
||||
export const CONTROL_UI_PERFORMANCE_BUDGETS = Object.freeze({
|
||||
startupJsRequests: 18,
|
||||
startupCssRequests: 1,
|
||||
// 312 KiB accompanies the live-narration sidebar feature (2026-07): the
|
||||
// controller is a lazy chunk; only its thin element/pref wiring (~1.5 KiB)
|
||||
// stays in startup, which exhausted the previous ratchet's headroom.
|
||||
startupJsGzipBytes: 312 * KIB,
|
||||
// 317 KiB preserves headroom after the device-auth upgrade hook and sidebar
|
||||
// session-render extraction (2026-07); the migration UI itself remains lazy.
|
||||
startupJsGzipBytes: 317 * KIB,
|
||||
// 45 KiB CSS ceilings maintainer-approved 2026-07 alongside the interleaved
|
||||
// sidebar zone styling; headroom over the ~36.5 KiB post-diet baseline.
|
||||
startupCssGzipBytes: 45 * KIB,
|
||||
@@ -126,6 +137,8 @@ export function collectControlUiPerformanceMetrics(distDir) {
|
||||
export function evaluateControlUiPerformanceBudgets(
|
||||
metrics,
|
||||
budgets = CONTROL_UI_PERFORMANCE_BUDGETS,
|
||||
startupBudgetBaseline = null,
|
||||
startupJsTolerance = CONTROL_UI_STARTUP_JS_GZIP_TOLERANCE_BYTES,
|
||||
) {
|
||||
const checks = [
|
||||
["startup JS requests", metrics.startup.js.requests, budgets.startupJsRequests, "count"],
|
||||
@@ -135,9 +148,23 @@ export function evaluateControlUiPerformanceBudgets(
|
||||
["largest JS gzip", metrics.largest.js.gzipBytes, budgets.largestJsGzipBytes, "bytes"],
|
||||
["largest CSS gzip", metrics.largest.css.gzipBytes, budgets.largestCssGzipBytes, "bytes"],
|
||||
];
|
||||
return checks.flatMap(([metric, actual, limit, unit]) =>
|
||||
const violations = checks.flatMap(([metric, actual, limit, unit]) =>
|
||||
actual > limit ? [{ metric, actual, limit, unit }] : [],
|
||||
);
|
||||
if (
|
||||
startupBudgetBaseline &&
|
||||
metrics.startup.js.gzipBytes > startupBudgetBaseline.startupJsGzipBytes + startupJsTolerance
|
||||
) {
|
||||
violations.push({
|
||||
metric: "startup JS gzip vs baseline",
|
||||
actual: metrics.startup.js.gzipBytes,
|
||||
limit: startupBudgetBaseline.startupJsGzipBytes + startupJsTolerance,
|
||||
unit: "bytes",
|
||||
baseline: startupBudgetBaseline.startupJsGzipBytes,
|
||||
tolerance: startupJsTolerance,
|
||||
});
|
||||
}
|
||||
return violations;
|
||||
}
|
||||
|
||||
export function formatControlUiPerformanceBytes(bytes) {
|
||||
@@ -153,6 +180,9 @@ function formatAssetSummary(summary) {
|
||||
}
|
||||
|
||||
function formatViolation(violation) {
|
||||
if (violation.baseline !== undefined && violation.tolerance !== undefined) {
|
||||
return `${violation.metric}: ${violation.actual} B exceeds baseline ${violation.baseline} B + tolerance ${violation.tolerance} B (limit ${violation.limit} B); intentionally raise the baseline with node scripts/check-control-ui-performance.mjs --update-baseline --startup-js-bytes ${violation.actual} --reason "<reason>"`;
|
||||
}
|
||||
const actual =
|
||||
violation.unit === "bytes"
|
||||
? formatControlUiPerformanceBytes(violation.actual)
|
||||
@@ -171,17 +201,40 @@ function formatViolation(violation) {
|
||||
export function formatControlUiPerformanceReport(
|
||||
metrics,
|
||||
budgets = CONTROL_UI_PERFORMANCE_BUDGETS,
|
||||
startupBudgetBaseline = null,
|
||||
startupJsTolerance = CONTROL_UI_STARTUP_JS_GZIP_TOLERANCE_BYTES,
|
||||
) {
|
||||
const violations = evaluateControlUiPerformanceBudgets(metrics, budgets);
|
||||
const violations = evaluateControlUiPerformanceBudgets(
|
||||
metrics,
|
||||
budgets,
|
||||
startupBudgetBaseline,
|
||||
startupJsTolerance,
|
||||
);
|
||||
const lines = [
|
||||
"Control UI performance:",
|
||||
` startup JS: ${formatAssetSummary(metrics.startup.js)} (limits: ${formatRequestCount(budgets.startupJsRequests)}, ${formatControlUiPerformanceBytes(budgets.startupJsGzipBytes)} gzip)`,
|
||||
];
|
||||
if (startupBudgetBaseline) {
|
||||
lines.push(
|
||||
` startup JS gzip vs baseline: ${metrics.startup.js.gzipBytes} B (baseline ${startupBudgetBaseline.startupJsGzipBytes} B + tolerance ${startupJsTolerance} B, ceiling ${budgets.startupJsGzipBytes} B)`,
|
||||
);
|
||||
}
|
||||
lines.push(
|
||||
` startup CSS: ${formatAssetSummary(metrics.startup.css)} (limits: ${formatRequestCount(budgets.startupCssRequests)}, ${formatControlUiPerformanceBytes(budgets.startupCssGzipBytes)} gzip)`,
|
||||
` largest JS: ${metrics.largest.js.file}, ${formatControlUiPerformanceBytes(metrics.largest.js.gzipBytes)} gzip (limit: ${formatControlUiPerformanceBytes(budgets.largestJsGzipBytes)})`,
|
||||
` largest CSS: ${metrics.largest.css.file}, ${formatControlUiPerformanceBytes(metrics.largest.css.gzipBytes)} gzip (limit: ${formatControlUiPerformanceBytes(budgets.largestCssGzipBytes)})`,
|
||||
` all JS: ${formatAssetSummary(metrics.total.js)}`,
|
||||
` all CSS: ${formatAssetSummary(metrics.total.css)}`,
|
||||
];
|
||||
);
|
||||
if (
|
||||
startupBudgetBaseline &&
|
||||
metrics.startup.js.gzipBytes + STARTUP_JS_BASELINE_RATCHET_BYTES <
|
||||
startupBudgetBaseline.startupJsGzipBytes
|
||||
) {
|
||||
lines.push(
|
||||
` hint: startup JS gzip is more than ${STARTUP_JS_BASELINE_RATCHET_BYTES} B below the ${startupBudgetBaseline.startupJsGzipBytes} B baseline; lower it with node scripts/check-control-ui-performance.mjs --update-baseline --reason "<reason>"`,
|
||||
);
|
||||
}
|
||||
if (violations.length > 0) {
|
||||
lines.push(
|
||||
" violations:",
|
||||
@@ -191,24 +244,147 @@ export function formatControlUiPerformanceReport(
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export function runControlUiPerformanceCheck(distDir, budgets = CONTROL_UI_PERFORMANCE_BUDGETS) {
|
||||
function baselineUpdateCommand() {
|
||||
return 'node scripts/check-control-ui-performance.mjs --update-baseline --reason "<reason>"';
|
||||
}
|
||||
|
||||
function isIsoDate(value) {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/u.test(value)) {
|
||||
return false;
|
||||
}
|
||||
const date = new Date(`${value}T00:00:00Z`);
|
||||
return !Number.isNaN(date.valueOf()) && date.toISOString().slice(0, 10) === value;
|
||||
}
|
||||
|
||||
function readControlUiStartupBudgetBaseline(baselinePath) {
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(baselinePath, "utf8"));
|
||||
if (
|
||||
!parsed ||
|
||||
typeof parsed !== "object" ||
|
||||
!Number.isSafeInteger(parsed.startupJsGzipBytes) ||
|
||||
parsed.startupJsGzipBytes < 0 ||
|
||||
typeof parsed.reason !== "string" ||
|
||||
parsed.reason.trim().length === 0 ||
|
||||
typeof parsed.updatedAt !== "string" ||
|
||||
!isIsoDate(parsed.updatedAt)
|
||||
) {
|
||||
throw new Error("expected startupJsGzipBytes, non-empty reason, and YYYY-MM-DD updatedAt");
|
||||
}
|
||||
return {
|
||||
startupJsGzipBytes: parsed.startupJsGzipBytes,
|
||||
reason: parsed.reason,
|
||||
updatedAt: parsed.updatedAt,
|
||||
};
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(
|
||||
`Cannot read Control UI startup budget baseline ${baselinePath}: ${detail}. Regenerate it with ${baselineUpdateCommand()}.`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function writeControlUiStartupBudgetBaseline(baselinePath, startupJsGzipBytes, reason) {
|
||||
const baseline = {
|
||||
startupJsGzipBytes,
|
||||
reason,
|
||||
updatedAt: new Date().toISOString().slice(0, 10),
|
||||
};
|
||||
fs.writeFileSync(baselinePath, `${JSON.stringify(baseline, null, 2)}\n`);
|
||||
return baseline;
|
||||
}
|
||||
|
||||
function validateExplicitStartupJsBytes(startupJsBytes, currentBaseline) {
|
||||
const delta = Math.abs(startupJsBytes - currentBaseline.startupJsGzipBytes);
|
||||
if (delta > STARTUP_JS_BASELINE_RATCHET_BYTES) {
|
||||
throw new Error(
|
||||
`startup JS gzip baseline update: ${startupJsBytes} B differs from current baseline ${currentBaseline.startupJsGzipBytes} B by ${delta} B, exceeding the ${STARTUP_JS_BASELINE_RATCHET_BYTES} B ratchet`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function runControlUiPerformanceCheck(
|
||||
distDir,
|
||||
budgets = CONTROL_UI_PERFORMANCE_BUDGETS,
|
||||
baselinePath = DEFAULT_STARTUP_BUDGET_BASELINE_PATH,
|
||||
) {
|
||||
const startupBudgetBaseline = readControlUiStartupBudgetBaseline(baselinePath);
|
||||
const metrics = collectControlUiPerformanceMetrics(distDir);
|
||||
const violations = evaluateControlUiPerformanceBudgets(metrics, budgets, startupBudgetBaseline);
|
||||
const report = formatControlUiPerformanceReport(metrics, budgets, startupBudgetBaseline);
|
||||
return {
|
||||
metrics,
|
||||
budgets,
|
||||
violations: evaluateControlUiPerformanceBudgets(metrics, budgets),
|
||||
report: formatControlUiPerformanceReport(metrics, budgets),
|
||||
startupBudgetBaseline,
|
||||
startupJsTolerance: CONTROL_UI_STARTUP_JS_GZIP_TOLERANCE_BYTES,
|
||||
violations,
|
||||
report,
|
||||
};
|
||||
}
|
||||
|
||||
function main(argv = process.argv.slice(2)) {
|
||||
const unknown = argv.filter((arg) => arg !== "--json");
|
||||
if (unknown.length > 0) {
|
||||
throw new Error(`Unknown option: ${unknown[0]}`);
|
||||
let json = false;
|
||||
let updateBaseline = false;
|
||||
let reason;
|
||||
let startupJsBytes;
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg === "--json") {
|
||||
json = true;
|
||||
} else if (arg === "--update-baseline") {
|
||||
updateBaseline = true;
|
||||
} else if (arg === "--reason") {
|
||||
reason = argv[index + 1];
|
||||
if (!reason || reason.trim().length === 0 || reason.startsWith("--")) {
|
||||
throw new Error("--reason requires a non-empty value");
|
||||
}
|
||||
index += 1;
|
||||
} else if (arg === "--startup-js-bytes") {
|
||||
const value = argv[index + 1];
|
||||
if (!value || !/^[1-9]\d*$/u.test(value)) {
|
||||
throw new Error("--startup-js-bytes requires a positive integer");
|
||||
}
|
||||
startupJsBytes = Number(value);
|
||||
if (!Number.isSafeInteger(startupJsBytes)) {
|
||||
throw new Error("--startup-js-bytes requires a positive integer");
|
||||
}
|
||||
index += 1;
|
||||
} else {
|
||||
throw new Error(`Unknown option: ${arg}`);
|
||||
}
|
||||
}
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const result = runControlUiPerformanceCheck(path.resolve(here, "../dist/control-ui"));
|
||||
if (argv.includes("--json")) {
|
||||
if (reason !== undefined && !updateBaseline) {
|
||||
throw new Error("--reason requires --update-baseline");
|
||||
}
|
||||
if (startupJsBytes !== undefined && !updateBaseline) {
|
||||
throw new Error("--startup-js-bytes requires --update-baseline");
|
||||
}
|
||||
if (json && updateBaseline) {
|
||||
throw new Error("--json cannot be combined with --update-baseline");
|
||||
}
|
||||
const distDir = path.resolve(SCRIPT_DIR, "../dist/control-ui");
|
||||
if (updateBaseline) {
|
||||
if (startupJsBytes !== undefined) {
|
||||
const currentBaseline = readControlUiStartupBudgetBaseline(
|
||||
DEFAULT_STARTUP_BUDGET_BASELINE_PATH,
|
||||
);
|
||||
validateExplicitStartupJsBytes(startupJsBytes, currentBaseline);
|
||||
}
|
||||
const nextStartupJsBytes =
|
||||
startupJsBytes ?? collectControlUiPerformanceMetrics(distDir).startup.js.gzipBytes;
|
||||
const baseline = writeControlUiStartupBudgetBaseline(
|
||||
DEFAULT_STARTUP_BUDGET_BASELINE_PATH,
|
||||
nextStartupJsBytes,
|
||||
reason ?? "manual baseline update",
|
||||
);
|
||||
process.stdout.write(
|
||||
`Updated config/control-ui-startup-budget-baseline.json to ${baseline.startupJsGzipBytes} B (${baseline.reason}).\n`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const result = runControlUiPerformanceCheck(distDir);
|
||||
if (json) {
|
||||
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
||||
} else {
|
||||
process.stdout.write(`${result.report}\n`);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,10 @@
|
||||
/**
|
||||
* Collects dependency pin violations for the current workspace.
|
||||
*/
|
||||
export function collectDependencyPinViolations(cwd?: string): unknown[];
|
||||
export function collectDependencyPinViolations(
|
||||
cwd?: string,
|
||||
options?: { gitTimeoutMs?: number },
|
||||
): unknown[];
|
||||
/**
|
||||
* Runs the dependency pin check.
|
||||
*/
|
||||
|
||||
@@ -15,12 +15,30 @@ const EXACT_NPM_ALIAS_PATTERN =
|
||||
const PINNED_GIT_PATTERN = /(?:#|\/commit\/)[0-9a-f]{40}$/iu;
|
||||
const PINNED_GITHUB_TARBALL_PATTERN =
|
||||
/^https:\/\/codeload\.github\.com\/[^/\s]+\/[^/\s]+\/tar\.gz\/[0-9a-f]{40}$/iu;
|
||||
const DEFAULT_GIT_TIMEOUT_MS = 60_000;
|
||||
|
||||
function listTrackedPackageJsonFiles(cwd) {
|
||||
return execFileSync("git", ["ls-files", "-z", "--", "*package.json"], {
|
||||
cwd,
|
||||
encoding: "utf8",
|
||||
})
|
||||
function runGit(cwd, args, timeoutMs = DEFAULT_GIT_TIMEOUT_MS) {
|
||||
try {
|
||||
return execFileSync("git", args, {
|
||||
cwd,
|
||||
encoding: "utf8",
|
||||
timeout: timeoutMs,
|
||||
// A synchronous child that ignores SIGTERM otherwise keeps its parent blocked.
|
||||
killSignal: "SIGKILL",
|
||||
});
|
||||
} catch (error) {
|
||||
if (error?.code === "ETIMEDOUT") {
|
||||
throw new Error(
|
||||
`dependency pin guard: git ${args.join(" ")} timed out after ${timeoutMs}ms.`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function listTrackedPackageJsonFiles(cwd, timeoutMs = DEFAULT_GIT_TIMEOUT_MS) {
|
||||
return runGit(cwd, ["ls-files", "-z", "--", "*package.json"], timeoutMs)
|
||||
.split("\0")
|
||||
.filter(Boolean)
|
||||
.toSorted((left, right) => left.localeCompare(right));
|
||||
@@ -30,17 +48,12 @@ function readJson(filePath) {
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
||||
}
|
||||
|
||||
function readTrackedJson(cwd, relativePath) {
|
||||
function readTrackedJson(cwd, relativePath, timeoutMs = DEFAULT_GIT_TIMEOUT_MS) {
|
||||
const filePath = path.join(cwd, relativePath);
|
||||
if (fs.existsSync(filePath)) {
|
||||
return readJson(filePath);
|
||||
}
|
||||
return JSON.parse(
|
||||
execFileSync("git", ["show", `:${relativePath}`], {
|
||||
cwd,
|
||||
encoding: "utf8",
|
||||
}),
|
||||
);
|
||||
return JSON.parse(runGit(cwd, ["show", `:${relativePath}`], timeoutMs));
|
||||
}
|
||||
|
||||
function isAllowedPinnedSpec(spec) {
|
||||
@@ -62,10 +75,10 @@ function isAllowedPinnedSpec(spec) {
|
||||
return false;
|
||||
}
|
||||
|
||||
function collectPackageJsonViolations(cwd) {
|
||||
function collectPackageJsonViolations(cwd, timeoutMs = DEFAULT_GIT_TIMEOUT_MS) {
|
||||
const violations = [];
|
||||
for (const relativePath of listTrackedPackageJsonFiles(cwd)) {
|
||||
const packageJson = readTrackedJson(cwd, relativePath);
|
||||
for (const relativePath of listTrackedPackageJsonFiles(cwd, timeoutMs)) {
|
||||
const packageJson = readTrackedJson(cwd, relativePath, timeoutMs);
|
||||
for (const section of PACKAGE_DEPENDENCY_SECTIONS) {
|
||||
for (const [name, spec] of Object.entries(packageJson[section] ?? {})) {
|
||||
if (!isAllowedPinnedSpec(spec)) {
|
||||
@@ -109,9 +122,15 @@ function collectWorkspaceViolations(cwd) {
|
||||
|
||||
/**
|
||||
* Collects dependency pin violations for the current workspace.
|
||||
*
|
||||
* @param {string} [cwd]
|
||||
* @param {{ gitTimeoutMs?: number }} [options]
|
||||
*/
|
||||
export function collectDependencyPinViolations(cwd = process.cwd()) {
|
||||
return [...collectPackageJsonViolations(cwd), ...collectWorkspaceViolations(cwd)];
|
||||
export function collectDependencyPinViolations(
|
||||
cwd = process.cwd(),
|
||||
{ gitTimeoutMs = DEFAULT_GIT_TIMEOUT_MS } = {},
|
||||
) {
|
||||
return [...collectPackageJsonViolations(cwd, gitTimeoutMs), ...collectWorkspaceViolations(cwd)];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,7 +6,12 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { laneResources, laneWeight } from "./lib/docker-e2e-plan.mjs";
|
||||
import { allReleasePathLanes, mainLanes, tailLanes } from "./lib/docker-e2e-scenarios.mjs";
|
||||
import {
|
||||
allReleasePathLanes,
|
||||
mainLanes,
|
||||
publicInstallerLanes,
|
||||
tailLanes,
|
||||
} from "./lib/docker-e2e-scenarios.mjs";
|
||||
|
||||
const ROOT_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const errors = [];
|
||||
@@ -47,13 +52,43 @@ function walk(dir, out = []) {
|
||||
return out;
|
||||
}
|
||||
|
||||
function findRelativeModuleSpecifiers(text) {
|
||||
const specifiers = new Set();
|
||||
for (const pattern of [
|
||||
/(?:\bfrom\s*|\bimport\s*\()\s*["']([^"']+)["']/gu,
|
||||
/\bimport\s*["']([^"']+)["']/gu,
|
||||
]) {
|
||||
for (const match of text.matchAll(pattern)) {
|
||||
const specifier = match[1];
|
||||
if (specifier?.startsWith(".")) {
|
||||
specifiers.add(specifier);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...specifiers];
|
||||
}
|
||||
|
||||
function isPathWithin(parent, candidate) {
|
||||
const relative = path.relative(parent, candidate);
|
||||
return (
|
||||
relative === "" ||
|
||||
(!path.isAbsolute(relative) && !relative.startsWith(`..${path.sep}`) && relative !== "..")
|
||||
);
|
||||
}
|
||||
|
||||
for (const relativePath of walk("scripts/e2e")) {
|
||||
if (!/\.(?:sh|ts|mjs|js)$/u.test(relativePath)) {
|
||||
continue;
|
||||
}
|
||||
const text = readText(relativePath);
|
||||
if (/from\s+["']\.\.\/\.\.\/src\//u.test(text) || /import\(["']\.\.\/\.\.\/src\//u.test(text)) {
|
||||
errors.push(`${relativePath}: Docker E2E harness must import built dist, not ../../src`);
|
||||
const sourceImport = findRelativeModuleSpecifiers(text).find((specifier) => {
|
||||
const resolved = path.resolve(ROOT_DIR, path.dirname(relativePath), specifier);
|
||||
return isPathWithin(path.join(ROOT_DIR, "src"), resolved);
|
||||
});
|
||||
if (sourceImport) {
|
||||
errors.push(
|
||||
`${relativePath}: Docker E2E harness must import package exports, not ${sourceImport}`,
|
||||
);
|
||||
}
|
||||
if (/-v\s+["']?\$ROOT_DIR:\/app(?::|["'\s]|$)/u.test(text)) {
|
||||
errors.push(`${relativePath}: do not mount the repo root as /app in Docker E2E`);
|
||||
@@ -121,6 +156,7 @@ function validateLane(label, lane) {
|
||||
const releasePathLanes = allReleasePathLanes({ includeOpenWebUI: true });
|
||||
for (const [label, lanes] of [
|
||||
["release-path", releasePathLanes],
|
||||
["public-installer", publicInstallerLanes],
|
||||
["main", mainLanes],
|
||||
["tail", tailLanes],
|
||||
]) {
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export function isCountedSourcePath(filePath: string): boolean;
|
||||
export function collectEnvVarNames(root?: string, options?: { staged?: boolean }): string[];
|
||||
export function parseBudget(source: string): number;
|
||||
export function main(argv?: string[], root?: string): number;
|
||||
@@ -0,0 +1,137 @@
|
||||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const BUDGET_PATH = "config/env-var-count-budget.txt";
|
||||
const SOURCE_ROOTS = ["src", "packages", "extensions"];
|
||||
const SOURCE_EXTENSIONS = new Set([".cjs", ".cts", ".js", ".jsx", ".mjs", ".mts", ".ts", ".tsx"]);
|
||||
const ENV_VAR_PATTERN = /OPENCLAW_[A-Z0-9_]+/gu;
|
||||
|
||||
export function isCountedSourcePath(filePath) {
|
||||
const normalized = filePath.replaceAll("\\", "/");
|
||||
if (!SOURCE_ROOTS.some((root) => normalized.startsWith(root + "/"))) {
|
||||
return false;
|
||||
}
|
||||
if (!SOURCE_EXTENSIONS.has(path.posix.extname(normalized))) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
/^(?:extensions\/(?:qa-lab|test-support)|.*\/(?:__tests__|test|tests|test-utils|test-support))\//u.test(
|
||||
normalized,
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return !/(?:^|[./-])(?:e2e|live-helpers|live-harness|spec|suite|test|test-helpers|test-harness|test-setup|test-support|test-utils)(?:[./-]|$)/u.test(
|
||||
normalized,
|
||||
);
|
||||
}
|
||||
|
||||
export function collectEnvVarNames(root = process.cwd(), options = {}) {
|
||||
const staged = options.staged === true;
|
||||
const files = execFileSync(
|
||||
"git",
|
||||
[
|
||||
"ls-files",
|
||||
"-z",
|
||||
"--cached",
|
||||
...(staged ? [] : ["--others", "--exclude-standard"]),
|
||||
"--",
|
||||
...SOURCE_ROOTS,
|
||||
],
|
||||
{ cwd: root, maxBuffer: 256 * 1024 * 1024 },
|
||||
)
|
||||
.toString("utf8")
|
||||
.split("\0")
|
||||
.filter(isCountedSourcePath)
|
||||
.filter((file) => staged || fs.existsSync(path.join(root, file)));
|
||||
const names = new Set();
|
||||
for (const file of files) {
|
||||
const source = staged
|
||||
? execFileSync("git", ["show", `:${file}`], { cwd: root, encoding: "utf8" })
|
||||
: fs.readFileSync(path.join(root, file), "utf8");
|
||||
for (const match of source.matchAll(ENV_VAR_PATTERN)) {
|
||||
names.add(match[0]);
|
||||
}
|
||||
}
|
||||
return [...names].toSorted((left, right) => (left < right ? -1 : left > right ? 1 : 0));
|
||||
}
|
||||
|
||||
export function parseBudget(source) {
|
||||
const values = source
|
||||
.split(/\r?\n/u)
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line && !line.startsWith("#"));
|
||||
if (values.length !== 1 || !/^\d+$/u.test(values[0])) {
|
||||
throw new Error(`${BUDGET_PATH} must contain exactly one non-negative integer`);
|
||||
}
|
||||
return Number(values[0]);
|
||||
}
|
||||
|
||||
function readBaseBudget(root, ref) {
|
||||
const resolved = spawnSync("git", ["rev-parse", "--verify", `${ref}^{commit}`], {
|
||||
cwd: root,
|
||||
encoding: "utf8",
|
||||
});
|
||||
if (resolved.status !== 0) {
|
||||
throw new Error(`Could not resolve env-var count base ref: ${ref}`);
|
||||
}
|
||||
const mergeBase = spawnSync("git", ["merge-base", "HEAD", ref], {
|
||||
cwd: root,
|
||||
encoding: "utf8",
|
||||
});
|
||||
const baselineRef = mergeBase.stdout.trim();
|
||||
if (mergeBase.status !== 0 || !baselineRef) {
|
||||
throw new Error(`Could not resolve env-var count merge base for: ${ref}`);
|
||||
}
|
||||
const entry = execFileSync("git", ["ls-tree", "--name-only", baselineRef, "--", BUDGET_PATH], {
|
||||
cwd: root,
|
||||
encoding: "utf8",
|
||||
}).trim();
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
return parseBudget(
|
||||
execFileSync("git", ["show", `${baselineRef}:${BUDGET_PATH}`], {
|
||||
cwd: root,
|
||||
encoding: "utf8",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function main(argv = process.argv.slice(2), root = process.cwd()) {
|
||||
const baseIndex = argv.indexOf("--base");
|
||||
const baseRef = baseIndex >= 0 ? argv[baseIndex + 1] : "origin/main";
|
||||
const staged = argv.includes("--staged");
|
||||
const expectedLength = (baseIndex >= 0 ? 2 : 0) + (staged ? 1 : 0);
|
||||
if ((baseIndex >= 0 && !baseRef) || argv.length !== expectedLength) {
|
||||
throw new Error("Usage: node scripts/check-env-var-count.mjs [--staged] [--base <git-ref>]");
|
||||
}
|
||||
const budgetSource = staged
|
||||
? execFileSync("git", ["show", `:${BUDGET_PATH}`], { cwd: root, encoding: "utf8" })
|
||||
: fs.readFileSync(path.join(root, BUDGET_PATH), "utf8");
|
||||
const budget = parseBudget(budgetSource);
|
||||
const baseBudget = readBaseBudget(root, baseRef);
|
||||
if (baseBudget !== null && budget > baseBudget) {
|
||||
throw new Error(`OPENCLAW_* budget grew from ${baseBudget} to ${budget}`);
|
||||
}
|
||||
const names = collectEnvVarNames(root, { staged });
|
||||
if (names.length !== budget) {
|
||||
const direction = names.length > budget ? "exceeds" : "is below";
|
||||
throw new Error(
|
||||
`OPENCLAW_* count ${names.length} ${direction} budget ${budget}; update ${BUDGET_PATH}`,
|
||||
);
|
||||
}
|
||||
console.log(`OPENCLAW_* count ${names.length}/${budget}`);
|
||||
return names.length;
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,13 @@ const ts = require("typescript");
|
||||
|
||||
const repoRoot = resolveRepoRoot(import.meta.url);
|
||||
const sourceRoots = [path.join(repoRoot, "src")];
|
||||
const nodeSqliteBoundaryRoots = [
|
||||
path.join(repoRoot, "src"),
|
||||
path.join(repoRoot, "extensions"),
|
||||
path.join(repoRoot, "packages"),
|
||||
];
|
||||
|
||||
const nodeSqliteConstructorOwnerPaths = new Set(["src/infra/node-sqlite.ts"]);
|
||||
|
||||
const kyselyRawAllowPaths = new Set(["src/infra/kysely-sync.ts"]);
|
||||
|
||||
@@ -40,8 +47,10 @@ const rawSqliteAllowPathGroups = {
|
||||
"src/infra/sqlite-wal.ts",
|
||||
"src/state/openclaw-agent-db-maintenance.ts",
|
||||
"src/state/openclaw-agent-db-registry.ts",
|
||||
"src/state/openclaw-agent-db-registry-listing.ts",
|
||||
"src/state/openclaw-agent-db-schema-helpers.ts",
|
||||
"src/state/openclaw-agent-db-schema.ts",
|
||||
"src/state/openclaw-agent-db-session-nodes-migration.ts",
|
||||
"src/state/openclaw-agent-db-session-migrations.ts",
|
||||
"src/state/openclaw-agent-db-session-provenance.ts",
|
||||
"src/state/openclaw-agent-db.ts",
|
||||
@@ -54,6 +63,7 @@ const rawSqliteAllowPathGroups = {
|
||||
"src/state/openclaw-state-db-schema-repair.ts",
|
||||
"src/state/openclaw-state-db-startup-checkpoint.ts",
|
||||
"src/state/openclaw-state-db.ts",
|
||||
"src/transcripts/sqlite-schema.ts",
|
||||
"src/state/sqlite-schema-shape.test-support.ts",
|
||||
],
|
||||
"cross-process SQLite coordination locks": ["src/infra/device-identity-coordinator.ts"],
|
||||
@@ -67,6 +77,9 @@ const rawSqliteAllowPathGroups = {
|
||||
"src/state/openclaw-agent-db-readonly.ts",
|
||||
"src/state/openclaw-state-db-readonly.ts",
|
||||
],
|
||||
"cold-process read-only relay lookup avoids the shared state writer lifecycle": [
|
||||
"src/agents/harness/native-hook-relay-client-store.ts",
|
||||
],
|
||||
"read-only schema preflight and integrity verification access": [
|
||||
"src/state/openclaw-database-preflight.ts",
|
||||
"src/state/openclaw-database-verify.worker.ts",
|
||||
@@ -79,6 +92,7 @@ const rawSqliteAllowPathGroups = {
|
||||
"src/commands/status.scan.shared.ts",
|
||||
],
|
||||
"doctor SQLite maintenance and legacy state migration": [
|
||||
"src/commands/doctor-agent-memory-schema.ts",
|
||||
"src/commands/doctor/cron/legacy-run-log-migration.ts",
|
||||
"src/commands/doctor/cron/migration-ledger.ts",
|
||||
"src/commands/doctor-sqlite-compact.ts",
|
||||
@@ -90,8 +104,16 @@ const rawSqliteAllowPathGroups = {
|
||||
"src/infra/state-migrations.storage.ts",
|
||||
"src/infra/state-migrations.cron-run-logs.ts",
|
||||
"src/infra/state-migrations.debug-proxy.ts",
|
||||
"src/infra/state-migrations.meeting-transcripts-detection.ts",
|
||||
"src/infra/state-migrations.meeting-transcripts-files.ts",
|
||||
"src/infra/state-migrations.meeting-transcripts-verify.ts",
|
||||
"src/infra/state-migrations.media-persistence.ts",
|
||||
],
|
||||
"shared database stores with direct DatabaseSync access": ["src/proxy-capture/store.sqlite.ts"],
|
||||
"session entry cache connection-local validity counters": [
|
||||
"src/config/sessions/session-accessor.sqlite-entry-cache.ts",
|
||||
],
|
||||
"device pairing cache connection-local validity counters": ["src/infra/device-pairing-store.ts"],
|
||||
"Kysely-backed stores that own a DatabaseSync boundary": [
|
||||
"src/acp/event-ledger.ts",
|
||||
"src/state/user-profiles.ts",
|
||||
@@ -230,6 +252,63 @@ function isSqliteStorePath(relativePath) {
|
||||
return relativePath.endsWith(".sqlite.ts") || relativePath.includes(".store.sqlite.ts");
|
||||
}
|
||||
|
||||
function collectNodeSqliteBoundaryViolations(content, relativePath) {
|
||||
if (isTestPath(relativePath) || nodeSqliteConstructorOwnerPaths.has(relativePath)) {
|
||||
return [];
|
||||
}
|
||||
const sourceFile = ts.createSourceFile(relativePath, content, ts.ScriptTarget.Latest, true);
|
||||
const constructorNames = new Set();
|
||||
|
||||
function collectConstructorNames(node) {
|
||||
if (ts.isImportDeclaration(node) && importSource(node) === "node:sqlite") {
|
||||
const namedBindings = node.importClause?.namedBindings;
|
||||
if (namedBindings && ts.isNamedImports(namedBindings)) {
|
||||
for (const element of namedBindings.elements) {
|
||||
if ((element.propertyName?.text ?? element.name.text) === "DatabaseSync") {
|
||||
constructorNames.add(element.name.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (ts.isVariableDeclaration(node) && ts.isObjectBindingPattern(node.name)) {
|
||||
for (const element of node.name.elements) {
|
||||
if (
|
||||
!element.dotDotDotToken &&
|
||||
ts.isIdentifier(element.name) &&
|
||||
(element.propertyName ? getPropertyNameText(element.propertyName) : element.name.text) ===
|
||||
"DatabaseSync"
|
||||
) {
|
||||
constructorNames.add(element.name.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
ts.forEachChild(node, collectConstructorNames);
|
||||
}
|
||||
|
||||
collectConstructorNames(sourceFile);
|
||||
const violations = [];
|
||||
function visit(node) {
|
||||
if (ts.isNewExpression(node)) {
|
||||
const expression = unwrapExpression(node.expression);
|
||||
const isRawConstructor =
|
||||
(ts.isIdentifier(expression) && constructorNames.has(expression.text)) ||
|
||||
(ts.isPropertyAccessExpression(expression) &&
|
||||
getPropertyNameText(expression.name) === "DatabaseSync");
|
||||
if (isRawConstructor) {
|
||||
addViolation(
|
||||
violations,
|
||||
sourceFile,
|
||||
node,
|
||||
"production node:sqlite connections must use openNodeSqliteDatabase",
|
||||
);
|
||||
}
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
}
|
||||
visit(sourceFile);
|
||||
return violations;
|
||||
}
|
||||
|
||||
function isLikelySqliteReceiver(expression) {
|
||||
const unwrapped = unwrapExpression(expression);
|
||||
if (ts.isIdentifier(unwrapped)) {
|
||||
@@ -398,6 +477,16 @@ async function collectKyselyGuardrails() {
|
||||
violations.push({ path: relativePath, ...violation });
|
||||
}
|
||||
}
|
||||
const nodeSqliteFiles = await collectTypeScriptFilesFromRoots(nodeSqliteBoundaryRoots, {
|
||||
includeTests: false,
|
||||
});
|
||||
for (const filePath of nodeSqliteFiles) {
|
||||
const relativePath = path.relative(repoRoot, filePath).split(path.sep).join("/");
|
||||
const content = await fs.readFile(filePath, "utf8");
|
||||
for (const violation of collectNodeSqliteBoundaryViolations(content, relativePath)) {
|
||||
violations.push({ path: relativePath, ...violation });
|
||||
}
|
||||
}
|
||||
return violations;
|
||||
}
|
||||
|
||||
|
||||
@@ -338,26 +338,22 @@ export function writeConfig({ homeDir, workspaceDir, port, token }) {
|
||||
agents: {
|
||||
defaults: {
|
||||
workspace: workspaceDir,
|
||||
memorySearch: {
|
||||
provider: "none",
|
||||
model: "",
|
||||
store: {
|
||||
vector: { enabled: false },
|
||||
},
|
||||
sync: {
|
||||
watch: true,
|
||||
onSessionStart: false,
|
||||
onSearch: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
list: [
|
||||
{
|
||||
id: "main",
|
||||
entries: {
|
||||
main: {
|
||||
default: true,
|
||||
tools: { allow: ["memory_search"] },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
memory: {
|
||||
search: {
|
||||
provider: "none",
|
||||
model: "",
|
||||
store: {
|
||||
vector: { enabled: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: { allow: ["memory-core"] },
|
||||
gateway: {
|
||||
|
||||
@@ -49,9 +49,9 @@ const allowedRawFetchCallsites = new Set([
|
||||
bundledPluginCallsite("qa-lab", "src/suite.ts", 330),
|
||||
bundledPluginCallsite("qa-lab", "src/suite.ts", 341),
|
||||
// The QA dashboard calls its same-origin local API from the browser, where server SSRF helpers do not run.
|
||||
bundledPluginCallsite("qa-lab", "web/src/http.ts", 8),
|
||||
bundledPluginCallsite("qa-lab", "web/src/http.ts", 16),
|
||||
bundledPluginCallsite("qa-lab", "web/src/http.ts", 27),
|
||||
bundledPluginCallsite("qa-lab", "web/src/http.ts", 24),
|
||||
bundledPluginCallsite("qa-lab", "web/src/http.ts", 32),
|
||||
bundledPluginCallsite("qa-lab", "web/src/http.ts", 43),
|
||||
bundledPluginCallsite("qqbot", "src/engine/api/api-client.ts", 124),
|
||||
bundledPluginCallsite("qqbot", "src/engine/api/media-chunked.ts", 554),
|
||||
bundledPluginCallsite("qqbot", "src/engine/api/token.ts", 211),
|
||||
|
||||
@@ -8,6 +8,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { performance } from "node:perf_hooks";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { gte as semverGte, valid as validSemver } from "semver";
|
||||
import { LOCAL_BUILD_METADATA_DIST_PATHS } from "./lib/local-build-metadata-paths.mjs";
|
||||
import {
|
||||
collectPackageDistImports,
|
||||
@@ -84,6 +85,11 @@ const REQUIRED_BUNDLED_WORKSPACE_RUNTIME_ENTRIES = new Map([
|
||||
[
|
||||
{ specifier: "@openclaw/ai", entry: "dist/index.mjs" },
|
||||
{ specifier: "@openclaw/ai/providers", entry: "dist/providers.mjs" },
|
||||
{
|
||||
specifier: "@openclaw/ai/transports",
|
||||
entry: "dist/transports.mjs",
|
||||
whenExported: "./transports",
|
||||
},
|
||||
{
|
||||
specifier: "@openclaw/ai/internal/runtime",
|
||||
entry: "dist/internal/runtime.mjs",
|
||||
@@ -176,7 +182,17 @@ function collectBundledPackageRuntimeErrors({ name, entries, files, packageRoot,
|
||||
if (bundledPackageJson.name !== name) {
|
||||
errors.push(`bundled ${name} package.json must name ${name}`);
|
||||
}
|
||||
const runtimeEntries = REQUIRED_BUNDLED_WORKSPACE_RUNTIME_ENTRIES.get(name) ?? [];
|
||||
const packageExports =
|
||||
bundledPackageJson.exports &&
|
||||
typeof bundledPackageJson.exports === "object" &&
|
||||
!Array.isArray(bundledPackageJson.exports)
|
||||
? bundledPackageJson.exports
|
||||
: {};
|
||||
// Trusted current-main harnesses validate frozen release targets. Require
|
||||
// post-cut runtime subpaths only when the candidate manifest owns them.
|
||||
const runtimeEntries = (REQUIRED_BUNDLED_WORKSPACE_RUNTIME_ENTRIES.get(name) ?? []).filter(
|
||||
({ whenExported }) => !whenExported || Object.hasOwn(packageExports, whenExported),
|
||||
);
|
||||
const resolutions = resolveBundledPackageSpecifiers(
|
||||
packageRoot,
|
||||
runtimeEntries.map(({ specifier }) => specifier),
|
||||
@@ -258,6 +274,8 @@ function collectRequiredBundledWorkspaceDependencyErrors(
|
||||
}
|
||||
|
||||
const phaseTimingsEnabled = process.env.OPENCLAW_PACKAGE_TARBALL_CHECK_TIMINGS !== "0";
|
||||
// Self-contained artifacts can exceed Node's 1 MiB spawnSync output default.
|
||||
const TAR_LIST_MAX_BUFFER_BYTES = 64 * 1024 * 1024;
|
||||
function runPhase(label, action) {
|
||||
const startedAt = performance.now();
|
||||
try {
|
||||
@@ -273,11 +291,12 @@ function runPhase(label, action) {
|
||||
const list = runPhase("tar list", () =>
|
||||
spawnSync("tar", ["-tf", tarball], {
|
||||
encoding: "utf8",
|
||||
maxBuffer: TAR_LIST_MAX_BUFFER_BYTES,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
}),
|
||||
);
|
||||
if (list.status !== 0) {
|
||||
fail(`tar -tf failed for ${tarball}: ${list.stderr || list.status}`);
|
||||
fail(`tar -tf failed for ${tarball}: ${list.stderr || list.error?.message || list.status}`);
|
||||
}
|
||||
|
||||
const extractDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-package-tarball-"));
|
||||
@@ -305,12 +324,17 @@ const normalized = entries.map((entry) => entry.replace(/^package\//u, ""));
|
||||
const entrySet = new Set(normalized);
|
||||
const errors = [];
|
||||
const warnings = [];
|
||||
const CODE_MODE_WORKER_PATH = "dist/agents/code-mode.worker.js";
|
||||
const FIRST_CODE_MODE_WORKER_VERSION = "2026.5.14-beta.2";
|
||||
const REQUIRED_TARBALL_ENTRIES = ["dist/control-ui/index.html", ...WORKSPACE_TEMPLATE_PACK_PATHS];
|
||||
const PACKAGE_INSTALL_GUARD_RELATIVE_PATH = "dist/openclaw-install-guard";
|
||||
const REQUIRED_TARBALL_ENTRY_PREFIXES = ["dist/control-ui/assets/"];
|
||||
const LEGACY_PACKAGE_ACCEPTANCE_COMPAT_MAX = { year: 2026, month: 4, day: 25 };
|
||||
const LEGACY_LOCAL_BUILD_METADATA_COMPAT_MAX = { year: 2026, month: 4, day: 26 };
|
||||
const LEGACY_SHRINKWRAP_COMPAT_MAX = { year: 2026, month: 5, day: 20 };
|
||||
const LEGACY_SHRINKWRAP_OMISSION_COMPAT_MAX = { year: 2026, month: 5, day: 20 };
|
||||
// 2026.7.2-beta.4 is the last published artifact known to ship shrinkwrap.
|
||||
// The whole 2026.7.2 train is transitional; later trains must be lockless.
|
||||
const NPM_SHRINKWRAP_TRANSITION_TRAIN = { year: 2026, month: 7, day: 2 };
|
||||
// 2026.7.1 shipped before the guard existed. Historical inspection may still check it.
|
||||
const LEGACY_INSTALL_GUARD_COMPAT_MAX = { year: 2026, month: 7, day: 1 };
|
||||
const FORBIDDEN_LOCAL_BUILD_METADATA_FILES = new Set(LOCAL_BUILD_METADATA_DIST_PATHS);
|
||||
@@ -375,16 +399,21 @@ function isLegacyLocalBuildMetadataCompatVersion(version) {
|
||||
return parsed ? compareCalver(parsed, LEGACY_LOCAL_BUILD_METADATA_COMPAT_MAX) <= 0 : false;
|
||||
}
|
||||
|
||||
function isLegacyShrinkwrapCompatVersion(version) {
|
||||
const parsed = parseCalver(version);
|
||||
return parsed ? compareCalver(parsed, LEGACY_SHRINKWRAP_COMPAT_MAX) <= 0 : false;
|
||||
}
|
||||
|
||||
function isLegacyInstallGuardCompatVersion(version) {
|
||||
const parsed = parseCalver(version);
|
||||
return parsed ? compareCalver(parsed, LEGACY_INSTALL_GUARD_COMPAT_MAX) <= 0 : false;
|
||||
}
|
||||
|
||||
function isLegacyShrinkwrapOmissionCompatVersion(version) {
|
||||
const parsed = parseCalver(version);
|
||||
return parsed ? compareCalver(parsed, LEGACY_SHRINKWRAP_OMISSION_COMPAT_MAX) <= 0 : false;
|
||||
}
|
||||
|
||||
function compareNpmShrinkwrapTransitionTrain(version) {
|
||||
const parsed = parseCalver(version);
|
||||
return parsed ? compareCalver(parsed, NPM_SHRINKWRAP_TRANSITION_TRAIN) : null;
|
||||
}
|
||||
|
||||
function readTarEntry(entryPath) {
|
||||
const candidates = [
|
||||
path.join(extractDir, entryPath),
|
||||
@@ -427,9 +456,10 @@ for (const requiredPrefix of REQUIRED_TARBALL_ENTRY_PREFIXES) {
|
||||
}
|
||||
}
|
||||
let packageVersion = "";
|
||||
let packageJson = null;
|
||||
if (entrySet.has("package.json")) {
|
||||
try {
|
||||
const packageJson = JSON.parse(readTarEntry("package.json"));
|
||||
packageJson = JSON.parse(readTarEntry("package.json"));
|
||||
packageVersion = typeof packageJson.version === "string" ? packageJson.version : "";
|
||||
errors.push(...collectWorkspaceProtocolDependencyErrors(packageJson, "package.json"));
|
||||
if (cliArgs.requireBundledWorkspaceDeps) {
|
||||
@@ -447,23 +477,39 @@ if (entrySet.has("package.json")) {
|
||||
packageVersion = "";
|
||||
}
|
||||
}
|
||||
const validPackageVersion = validSemver(packageVersion);
|
||||
const requiresCodeModeWorker =
|
||||
validPackageVersion !== null && semverGte(validPackageVersion, FIRST_CODE_MODE_WORKER_VERSION);
|
||||
if (requiresCodeModeWorker && !entrySet.has(CODE_MODE_WORKER_PATH)) {
|
||||
errors.push(`missing required tar entry ${CODE_MODE_WORKER_PATH}`);
|
||||
}
|
||||
if (entrySet.has("package-lock.json")) {
|
||||
errors.push("package tarball must ship npm-shrinkwrap.json, not package-lock.json");
|
||||
errors.push("package tarball must not contain package-lock.json");
|
||||
}
|
||||
if (!entrySet.has(PACKAGE_INSTALL_GUARD_RELATIVE_PATH)) {
|
||||
if (isLegacyInstallGuardCompatVersion(packageVersion)) {
|
||||
warnings.push("legacy package omits the preinstall completion guard");
|
||||
} else {
|
||||
errors.push(`missing required tar entry ${PACKAGE_INSTALL_GUARD_RELATIVE_PATH}`);
|
||||
const shrinkwrapTransitionComparison = compareNpmShrinkwrapTransitionTrain(packageVersion);
|
||||
const hasShrinkwrap = entrySet.has("npm-shrinkwrap.json");
|
||||
let shouldValidateShrinkwrap = false;
|
||||
if (shrinkwrapTransitionComparison !== null && shrinkwrapTransitionComparison > 0) {
|
||||
if (hasShrinkwrap) {
|
||||
errors.push("package tarball must not contain npm-shrinkwrap.json");
|
||||
}
|
||||
}
|
||||
if (!entrySet.has("npm-shrinkwrap.json")) {
|
||||
if (isLegacyShrinkwrapCompatVersion(packageVersion)) {
|
||||
} else if (shrinkwrapTransitionComparison === 0) {
|
||||
if (hasShrinkwrap) {
|
||||
warnings.push(
|
||||
"2026.7.2 transition package contains npm-shrinkwrap.json from the published beta train",
|
||||
);
|
||||
shouldValidateShrinkwrap = true;
|
||||
}
|
||||
} else if (!hasShrinkwrap) {
|
||||
if (isLegacyShrinkwrapOmissionCompatVersion(packageVersion)) {
|
||||
warnings.push("legacy package omits npm-shrinkwrap.json");
|
||||
} else {
|
||||
errors.push("missing required tar entry npm-shrinkwrap.json");
|
||||
errors.push("legacy package is missing required tar entry npm-shrinkwrap.json");
|
||||
}
|
||||
} else {
|
||||
shouldValidateShrinkwrap = true;
|
||||
}
|
||||
if (shouldValidateShrinkwrap) {
|
||||
try {
|
||||
const shrinkwrap = JSON.parse(readTarEntry("npm-shrinkwrap.json"));
|
||||
const rootPackage = shrinkwrap.packages?.[""];
|
||||
@@ -503,6 +549,13 @@ if (!entrySet.has("npm-shrinkwrap.json")) {
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!entrySet.has(PACKAGE_INSTALL_GUARD_RELATIVE_PATH)) {
|
||||
if (isLegacyInstallGuardCompatVersion(packageVersion)) {
|
||||
warnings.push("legacy package omits the preinstall completion guard");
|
||||
} else {
|
||||
errors.push(`missing required tar entry ${PACKAGE_INSTALL_GUARD_RELATIVE_PATH}`);
|
||||
}
|
||||
}
|
||||
for (const forbiddenEntry of FORBIDDEN_LOCAL_BUILD_METADATA_FILES) {
|
||||
if (entrySet.has(forbiddenEntry)) {
|
||||
if (isLegacyLocalBuildMetadataCompatVersion(packageVersion)) {
|
||||
@@ -526,11 +579,31 @@ if (entrySet.has("dist/postinstall-inventory.json")) {
|
||||
} else {
|
||||
const normalizedInventory = inventory.map((entry) => entry.replace(/\\/gu, "/"));
|
||||
const normalizedInventorySet = new Set(normalizedInventory);
|
||||
if (requiresCodeModeWorker && !normalizedInventorySet.has(CODE_MODE_WORKER_PATH)) {
|
||||
errors.push(`postinstall inventory omits ${CODE_MODE_WORKER_PATH}`);
|
||||
}
|
||||
if (normalizedInventorySet.has(PACKAGE_INSTALL_GUARD_RELATIVE_PATH)) {
|
||||
errors.push(
|
||||
`package dist inventory must omit install guard ${PACKAGE_INSTALL_GUARD_RELATIVE_PATH}`,
|
||||
);
|
||||
}
|
||||
if (typeof packageJson?.scripts?.postinstall === "string") {
|
||||
// Postinstall prunes every uninventoried dist file, including dashboard
|
||||
// assets that cannot be recovered from the JavaScript import graph.
|
||||
const requiredControlUiInventoryEntries = new Set([
|
||||
...REQUIRED_TARBALL_ENTRIES.filter((entry) => entry.startsWith("dist/")),
|
||||
...normalized.filter(
|
||||
(entry) =>
|
||||
REQUIRED_TARBALL_ENTRY_PREFIXES.some((prefix) => entry.startsWith(prefix)) &&
|
||||
fs.statSync(path.join(extractedPackageRoot, entry)).isFile(),
|
||||
),
|
||||
]);
|
||||
for (const requiredEntry of requiredControlUiInventoryEntries) {
|
||||
if (!normalizedInventorySet.has(requiredEntry)) {
|
||||
errors.push(`postinstall inventory omits Control UI file ${requiredEntry}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
packageDistImports = runPhase("dist import graph", () =>
|
||||
collectPackageDistImports({
|
||||
files: normalized,
|
||||
|
||||
@@ -13,7 +13,13 @@ import {
|
||||
} from "./lib/ts-guard-utils.mjs";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const scanRoots = resolveSourceRoots(repoRoot, ["src", "extensions", "scripts", "test"]);
|
||||
const scanRoots = resolveSourceRoots(repoRoot, [
|
||||
"src",
|
||||
"packages",
|
||||
"extensions",
|
||||
"scripts",
|
||||
"test",
|
||||
]);
|
||||
|
||||
function readPackageExports() {
|
||||
const packageJson = JSON.parse(readFileSync(path.join(repoRoot, "package.json"), "utf8"));
|
||||
@@ -49,6 +55,28 @@ function parsePluginSdkSubpath(specifier) {
|
||||
return subpath || null;
|
||||
}
|
||||
|
||||
function isGeneratedBuildArtifact(filePath) {
|
||||
return normalizeRepoPath(repoRoot, filePath).split("/").includes("dist");
|
||||
}
|
||||
|
||||
function isRuntimeModuleReference(node) {
|
||||
// With verbatimModuleSyntax, inline `type` specifiers emit an empty import/export and still
|
||||
// resolve the module. Only declaration-level `import type` and `export type` are erased.
|
||||
if (ts.isImportDeclaration(node)) {
|
||||
return !node.importClause?.isTypeOnly;
|
||||
}
|
||||
if (ts.isExportDeclaration(node)) {
|
||||
return !node.isTypeOnly;
|
||||
}
|
||||
if (ts.isImportTypeNode(node)) {
|
||||
return false;
|
||||
}
|
||||
if (ts.isImportEqualsDeclaration(node)) {
|
||||
return !node.isTypeOnly;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function compareEntries(left, right) {
|
||||
return (
|
||||
left.file.localeCompare(right.file) ||
|
||||
@@ -63,22 +91,44 @@ async function collectViolations() {
|
||||
const entrypoints = readEntrypoints();
|
||||
const exports = readPackageExports();
|
||||
const privateLocalOnlySubpaths = readPrivateLocalOnlySubpaths();
|
||||
const files = (await collectTypeScriptFilesFromRoots(scanRoots, { includeTests: true })).toSorted(
|
||||
(left, right) =>
|
||||
normalizeRepoPath(repoRoot, left).localeCompare(normalizeRepoPath(repoRoot, right)),
|
||||
// Workspace packages resolve private facades through root TS paths and bundle them into dist;
|
||||
// live jiti source stages inject the same private map. Core src callers must stay relative.
|
||||
const coreRuntimeFiles = new Set(
|
||||
(
|
||||
await collectTypeScriptFilesFromRoots(resolveSourceRoots(repoRoot, ["src"]), {
|
||||
includeTests: false,
|
||||
extraTestSuffixes: [".test-support.ts", ".test-loader.ts", ".test-fixtures.ts"],
|
||||
})
|
||||
).filter((filePath) => !isGeneratedBuildArtifact(filePath)),
|
||||
);
|
||||
const files = (await collectTypeScriptFilesFromRoots(scanRoots, { includeTests: true }))
|
||||
.filter((filePath) => !isGeneratedBuildArtifact(filePath))
|
||||
.toSorted((left, right) =>
|
||||
normalizeRepoPath(repoRoot, left).localeCompare(normalizeRepoPath(repoRoot, right)),
|
||||
);
|
||||
const violations = [];
|
||||
|
||||
for (const filePath of files) {
|
||||
const sourceText = readFileSync(filePath, "utf8");
|
||||
const sourceFile = ts.createSourceFile(filePath, sourceText, ts.ScriptTarget.Latest, true);
|
||||
|
||||
function push(kind, specifierNode, specifier) {
|
||||
function push(kind, node, specifierNode, specifier) {
|
||||
const subpath = parsePluginSdkSubpath(specifier);
|
||||
if (!subpath) {
|
||||
return;
|
||||
}
|
||||
if (privateLocalOnlySubpaths.has(subpath)) {
|
||||
const repoPath = normalizeRepoPath(repoRoot, filePath);
|
||||
if (coreRuntimeFiles.has(filePath) && isRuntimeModuleReference(node)) {
|
||||
violations.push({
|
||||
file: repoPath,
|
||||
line: toLine(sourceFile, specifierNode),
|
||||
kind,
|
||||
specifier,
|
||||
subpath,
|
||||
reason: "private runtime helper used by core must use a relative import",
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -99,12 +149,12 @@ async function collectViolations() {
|
||||
kind,
|
||||
specifier,
|
||||
subpath,
|
||||
missingFrom,
|
||||
reason: `missing from ${missingFrom.join(" and ")}`,
|
||||
});
|
||||
}
|
||||
|
||||
visitModuleSpecifiers(ts, sourceFile, ({ kind, specifier, specifierNode }) => {
|
||||
push(kind, specifierNode, specifier);
|
||||
visitModuleSpecifiers(ts, sourceFile, ({ kind, node, specifier, specifierNode }) => {
|
||||
push(kind, node, specifierNode, specifier);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -119,11 +169,11 @@ async function main() {
|
||||
}
|
||||
|
||||
console.error(
|
||||
"Rule: every referenced openclaw/plugin-sdk/<subpath> must exist in the public package exports.",
|
||||
"Rule: every referenced openclaw/plugin-sdk/<subpath> must be public or use its required private boundary.",
|
||||
);
|
||||
for (const violation of violations) {
|
||||
console.error(
|
||||
`- ${violation.file}:${violation.line} [${violation.kind}] ${violation.specifier} missing from ${violation.missingFrom.join(" and ")}`,
|
||||
`- ${violation.file}:${violation.line} [${violation.kind}] ${violation.specifier}: ${violation.reason}`,
|
||||
);
|
||||
}
|
||||
process.exit(1);
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const schemaDir = path.join(repoRoot, "packages/gateway-protocol/src/schema");
|
||||
const failures = [];
|
||||
const read = (relativePath) => fs.readFileSync(path.join(repoRoot, relativePath), "utf8");
|
||||
const check = (condition, message) => {
|
||||
if (!condition) {
|
||||
failures.push(message);
|
||||
}
|
||||
};
|
||||
|
||||
const registryPath = "packages/gateway-protocol/src/schema/protocol-schemas.ts";
|
||||
const registrySource = read(registryPath);
|
||||
const fragmentImports = [
|
||||
...registrySource.matchAll(
|
||||
/^import \{ ([A-Za-z0-9_]+) \} from "(\.\/protocol-schema-fragment-[^"]+\.js)";$/gmu,
|
||||
),
|
||||
].map((match) => ({ binding: match[1], specifier: match[2] }));
|
||||
const importSpecifiers = [...registrySource.matchAll(/^import .* from "([^"]+)";$/gmu)].map(
|
||||
(match) => match[1],
|
||||
);
|
||||
check(
|
||||
importSpecifiers.every(
|
||||
(specifier) =>
|
||||
specifier === "./protocol-schema-composer.js" ||
|
||||
specifier.startsWith("./protocol-schema-fragment-"),
|
||||
),
|
||||
`${registryPath} may import only the composer and schema fragments`,
|
||||
);
|
||||
check(
|
||||
!/\b[A-Z][A-Za-z0-9]*Schema\b/u.test(registrySource),
|
||||
`${registryPath} contains a direct *Schema inventory`,
|
||||
);
|
||||
|
||||
const composition = registrySource.match(
|
||||
/export const ProtocolSchemas = composeProtocolSchemaFragments\(\[([\s\S]*?)\]\s+as const\);/u,
|
||||
);
|
||||
const composedBindings = (composition?.[1] ?? "")
|
||||
.split("\n")
|
||||
.map((line) => line.trim().replace(/,$/u, ""))
|
||||
.filter(Boolean);
|
||||
const importedBindings = fragmentImports.map(({ binding }) => binding);
|
||||
check(Boolean(composition), `${registryPath} must explicitly compose an ordered fragment array`);
|
||||
check(
|
||||
composedBindings.length === importedBindings.length &&
|
||||
new Set(composedBindings).size === composedBindings.length &&
|
||||
importedBindings.every((binding) => composedBindings.includes(binding)),
|
||||
`${registryPath} must compose every imported fragment exactly once`,
|
||||
);
|
||||
|
||||
const fragmentFiles = fs
|
||||
.readdirSync(schemaDir)
|
||||
.filter((name) => /^protocol-schema-fragment-.+\.ts$/u.test(name));
|
||||
const importedFiles = fragmentImports.map(({ specifier }) => `${specifier.slice(2, -3)}.ts`);
|
||||
check(
|
||||
fragmentFiles.length === importedFiles.length &&
|
||||
fragmentFiles.every((name) => importedFiles.includes(name)),
|
||||
`${registryPath} must explicitly import every protocol schema fragment`,
|
||||
);
|
||||
|
||||
const importsByBinding = new Map(
|
||||
fragmentImports.map((fragmentImport) => [fragmentImport.binding, fragmentImport]),
|
||||
);
|
||||
const seenKeys = new Set();
|
||||
const orderedKeys = [];
|
||||
for (const binding of composedBindings) {
|
||||
const { specifier } = importsByBinding.get(binding) ?? {};
|
||||
if (!specifier) {
|
||||
continue;
|
||||
}
|
||||
const moduleUrl = new URL(specifier.replace(/\.js$/u, ".ts"), pathToFileURL(registryPath));
|
||||
const fragment = (await import(moduleUrl.href))[binding];
|
||||
check(fragment && typeof fragment === "object", `${specifier} must export object ${binding}`);
|
||||
if (!fragment || typeof fragment !== "object") {
|
||||
continue;
|
||||
}
|
||||
for (const key of Object.keys(fragment)) {
|
||||
check(!seenKeys.has(key), `duplicate protocol schema key ${key}`);
|
||||
seenKeys.add(key);
|
||||
orderedKeys.push(key);
|
||||
}
|
||||
}
|
||||
const { ProtocolSchemas } = await import(
|
||||
pathToFileURL(path.join(schemaDir, "protocol-schemas.ts"))
|
||||
);
|
||||
check(
|
||||
JSON.stringify(Object.keys(ProtocolSchemas)) === JSON.stringify(orderedKeys),
|
||||
"ProtocolSchemas must preserve explicit fragment/key order",
|
||||
);
|
||||
|
||||
const composerSource = read("packages/gateway-protocol/src/schema/protocol-schema-composer.ts");
|
||||
check(!/\.(?:sort|toSorted)\s*\(/u.test(composerSource), "schema composer must not sort");
|
||||
check(
|
||||
composerSource.includes("Object.hasOwn(registry, key)"),
|
||||
"schema composer must reject duplicate fragment keys",
|
||||
);
|
||||
|
||||
const withoutComments = (source) =>
|
||||
source
|
||||
.replace(/\r\n?/gu, "\n")
|
||||
.replace(/\/\*[\s\S]*?\*\//gu, "")
|
||||
.replace(/^\s*\/\/.*$/gmu, "")
|
||||
.trim();
|
||||
const schemaModulesSource = withoutComments(
|
||||
read("packages/gateway-protocol/src/schema-modules.ts"),
|
||||
);
|
||||
const ownerModules = [
|
||||
...schemaModulesSource.matchAll(/^export \* from "\.\/schema\/([^"]+)\.js";$/gmu),
|
||||
].map((match) => match[1]);
|
||||
check(
|
||||
ownerModules.length === 52 && new Set(ownerModules).size === ownerModules.length,
|
||||
"schema-modules.ts must contain one unique 52-module owner list",
|
||||
);
|
||||
check(
|
||||
schemaModulesSource.split("\n").filter(Boolean).length === ownerModules.length,
|
||||
"schema-modules.ts may contain only owner-module exports",
|
||||
);
|
||||
check(
|
||||
withoutComments(read("packages/gateway-protocol/src/schema.ts")) ===
|
||||
'export * from "./schema-modules.js";\nexport * from "./schema/protocol-schemas.js";',
|
||||
"schema.ts must remain a schema-modules/protocol-schemas wrapper",
|
||||
);
|
||||
check(
|
||||
withoutComments(read("packages/gateway-protocol/src/schema-types.ts")) ===
|
||||
'export type * from "./schema-modules.js";',
|
||||
"schema-types.ts must remain a registry-free schema-modules wrapper",
|
||||
);
|
||||
|
||||
for (const relativePath of [
|
||||
"packages/gateway-protocol/src/index.ts",
|
||||
"packages/gateway-protocol/src/schema-export-registry.ts",
|
||||
"packages/gateway-protocol/src/validator-registry.ts",
|
||||
]) {
|
||||
check(
|
||||
!read(relativePath).includes('from "./schema.js"'),
|
||||
`${relativePath} must not cross the registry through schema.ts`,
|
||||
);
|
||||
}
|
||||
const pluginSdkGuard = read("scripts/check-plugin-sdk-exports.mjs");
|
||||
check(
|
||||
pluginSdkGuard.includes("FORBIDDEN_PUBLIC_PROTOCOL_REGISTRY_RE") &&
|
||||
pluginSdkGuard.includes("FORBIDDEN PUBLIC DTS REGISTRY"),
|
||||
"plugin SDK declaration checks must reject leaked ProtocolSchemas declarations",
|
||||
);
|
||||
|
||||
if (failures.length) {
|
||||
throw new Error(
|
||||
failures.map((failure) => `protocol registry check failed: ${failure}`).join("\n"),
|
||||
);
|
||||
}
|
||||
console.log("protocol registry check passed");
|
||||
@@ -5,6 +5,10 @@ import { existsSync, readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { RELEASE_METADATA_PATHS } from "./changed-lanes.mjs";
|
||||
|
||||
const DEFAULT_GIT_TIMEOUT_MS = 60_000;
|
||||
const MAX_GIT_TIMEOUT_MS = 10 * 60_000;
|
||||
const GIT_TIMEOUT_ENV = "OPENCLAW_RELEASE_METADATA_GIT_TIMEOUT_MS";
|
||||
|
||||
const VERSION_ONLY_TEXT_PATHS = new Set([
|
||||
"apps/android/Config/Version.properties",
|
||||
"apps/android/version.json",
|
||||
@@ -26,6 +30,18 @@ function readRefOptionValue(argv, index, optionName) {
|
||||
return value;
|
||||
}
|
||||
|
||||
function resolveGitTimeoutMs(env = process.env) {
|
||||
const raw = env[GIT_TIMEOUT_ENV]?.trim();
|
||||
if (!raw) {
|
||||
return DEFAULT_GIT_TIMEOUT_MS;
|
||||
}
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return DEFAULT_GIT_TIMEOUT_MS;
|
||||
}
|
||||
return Math.max(1, Math.min(Math.trunc(parsed), MAX_GIT_TIMEOUT_MS));
|
||||
}
|
||||
|
||||
export function parseArgs(argv) {
|
||||
const separatorIndex = argv.indexOf("--");
|
||||
const flagArgv = separatorIndex === -1 ? argv : argv.slice(0, separatorIndex);
|
||||
@@ -52,12 +68,28 @@ export function parseArgs(argv) {
|
||||
return args;
|
||||
}
|
||||
|
||||
function formatGitArgs(args) {
|
||||
return args.join(" ");
|
||||
}
|
||||
|
||||
function git(args) {
|
||||
return execFileSync("git", args, {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
encoding: "utf8",
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
});
|
||||
const timeout = resolveGitTimeoutMs();
|
||||
try {
|
||||
return execFileSync("git", args, {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
encoding: "utf8",
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
timeout,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error?.signal === "SIGTERM" || error?.code === "ETIMEDOUT") {
|
||||
throw new Error(
|
||||
`release metadata guard: git ${formatGitArgs(args)} timed out after ${timeout}ms.`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function listChangedPaths(args) {
|
||||
|
||||
@@ -14,6 +14,10 @@ export function findSessionAccessorBoundaryViolations(
|
||||
content: unknown,
|
||||
fileName?: string,
|
||||
): unknown[];
|
||||
export function findReadOnlySessionAccessorViolations(
|
||||
content: unknown,
|
||||
fileName?: string,
|
||||
): unknown[];
|
||||
export function findEmbeddedAgentSessionTargetViolations(
|
||||
content: unknown,
|
||||
fileName?: string,
|
||||
@@ -71,3 +75,4 @@ export const migratedTranscriptWriterFiles: Set<string>;
|
||||
export const migratedSessionCompactManualTrimFiles: Set<string>;
|
||||
export const migratedSessionLifecycleCleanupFiles: Set<string>;
|
||||
export const migratedMemoryHostSessionCorpusFiles: Set<string>;
|
||||
export const readOnlyGatewaySessionAccessorFiles: Set<string>;
|
||||
|
||||
@@ -5,6 +5,7 @@ import path from "node:path";
|
||||
import ts from "typescript";
|
||||
import {
|
||||
collectFileViolations,
|
||||
getPropertyNameText,
|
||||
resolveRepoRoot,
|
||||
resolveSourceRoots,
|
||||
runAsScript,
|
||||
@@ -65,6 +66,7 @@ const sessionStoreRuntimeFileBackedCompatNames = new Set([
|
||||
"updateSessionStore",
|
||||
]);
|
||||
const embeddedAgentSessionFileRuntimeNames = new Set(["resolveSessionFilePath"]);
|
||||
const materializingSessionEntryAccessorNames = new Set(["listSessionEntries", "loadSessionEntry"]);
|
||||
|
||||
// Shipped beta.5 official plugins import these deprecated helpers during
|
||||
// doctor migrations. Remove this ratchet with the compatibility bridge once
|
||||
@@ -121,7 +123,7 @@ export const migratedSessionAccessorFiles = new Set([
|
||||
"src/commands/sessions-tail.ts",
|
||||
"src/commands/sessions.ts",
|
||||
"src/commands/status.agent-local.ts",
|
||||
"src/commands/status.summary.ts",
|
||||
"src/status/summary.ts",
|
||||
"src/commands/tasks.ts",
|
||||
"src/config/sessions/combined-store-gateway.ts",
|
||||
"src/config/sessions/delivery-info.ts",
|
||||
@@ -185,7 +187,9 @@ export const migratedSessionAccessorWriteFiles = new Set([
|
||||
"src/agents/embedded-agent-subscribe.handlers.compaction.runtime.ts",
|
||||
"src/agents/embedded-agent-runner/run/attempt.ts",
|
||||
"src/agents/live-model-switch.ts",
|
||||
"src/agents/main-session-restart-recovery.ts",
|
||||
"src/agents/main-session-restart-recovery-checkpoint.ts",
|
||||
"src/agents/main-session-restart-recovery-marking.ts",
|
||||
"src/agents/main-session-restart-recovery-store.ts",
|
||||
"src/agents/session-suspension.ts",
|
||||
"src/auto-reply/reply/abort.ts",
|
||||
"src/agents/subagent-control.ts",
|
||||
@@ -246,6 +250,25 @@ export const migratedSessionLifecycleCleanupFiles = new Set([
|
||||
"src/infra/heartbeat-runner.ts",
|
||||
]);
|
||||
|
||||
export const readOnlyGatewaySessionAccessorFiles = new Set([
|
||||
"src/gateway/approval-session-audience.ts",
|
||||
"src/gateway/control-ui-session-prs.ts",
|
||||
"src/gateway/managed-image-attachments.ts",
|
||||
"src/gateway/mcp-app-reconstruction.ts",
|
||||
"src/gateway/server-chat.ts",
|
||||
"src/gateway/server-methods/artifacts.ts",
|
||||
"src/gateway/server-methods/chat-history-handler.ts",
|
||||
"src/gateway/server-methods/chat-message-get-handler.ts",
|
||||
"src/gateway/server-methods/sessions-diff.ts",
|
||||
"src/gateway/server-methods/sessions-files.ts",
|
||||
"src/gateway/server-methods/sessions-read.ts",
|
||||
"src/gateway/server-methods/sessions-subscriptions.ts",
|
||||
"src/gateway/server-methods/task-suggestions.ts",
|
||||
"src/gateway/server-methods/tools-effective.ts",
|
||||
"src/gateway/server-methods/usage.ts",
|
||||
"src/gateway/server-session-events.ts",
|
||||
]);
|
||||
|
||||
export const migratedMemoryHostSessionCorpusFiles = new Set([
|
||||
"packages/memory-host-sdk/src/host/session-files.ts",
|
||||
"packages/memory-host-sdk/src/host/session-transcript-corpus.ts",
|
||||
@@ -293,13 +316,6 @@ function propertyAccessName(expression) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function propertyNameText(name) {
|
||||
if (ts.isIdentifier(name) || ts.isStringLiteralLike(name) || ts.isNumericLiteral(name)) {
|
||||
return name.text;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function bindingName(node) {
|
||||
if (node.propertyName && ts.isIdentifier(node.propertyName)) {
|
||||
return node.propertyName.text;
|
||||
@@ -313,6 +329,12 @@ function bindingName(node) {
|
||||
function findNamedBoundaryViolations(content, fileName, legacyNames, subject) {
|
||||
const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true);
|
||||
const violations = [];
|
||||
const addViolation = (node, action, name) => {
|
||||
violations.push({
|
||||
line: toLine(sourceFile, node),
|
||||
reason: `${action} ${subject} "${name}"`,
|
||||
});
|
||||
};
|
||||
|
||||
const visit = (node) => {
|
||||
if (ts.isImportDeclaration(node)) {
|
||||
@@ -321,10 +343,7 @@ function findNamedBoundaryViolations(content, fileName, legacyNames, subject) {
|
||||
for (const specifier of namedBindings.elements) {
|
||||
const importedName = specifier.propertyName?.text ?? specifier.name.text;
|
||||
if (legacyNames.has(importedName)) {
|
||||
violations.push({
|
||||
line: toLine(sourceFile, specifier),
|
||||
reason: `imports ${subject} "${importedName}"`,
|
||||
});
|
||||
addViolation(specifier, "imports", importedName);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -333,18 +352,12 @@ function findNamedBoundaryViolations(content, fileName, legacyNames, subject) {
|
||||
if (ts.isBindingElement(node)) {
|
||||
const name = bindingName(node);
|
||||
if (name && legacyNames.has(name)) {
|
||||
violations.push({
|
||||
line: toLine(sourceFile, node),
|
||||
reason: `aliases ${subject} "${name}"`,
|
||||
});
|
||||
addViolation(node, "aliases", name);
|
||||
}
|
||||
}
|
||||
|
||||
if (ts.isPropertyAccessExpression(node) && legacyNames.has(node.name.text)) {
|
||||
violations.push({
|
||||
line: toLine(sourceFile, node.name),
|
||||
reason: `references ${subject} "${node.name.text}"`,
|
||||
});
|
||||
addViolation(node.name, "references", node.name.text);
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -352,10 +365,7 @@ function findNamedBoundaryViolations(content, fileName, legacyNames, subject) {
|
||||
ts.isStringLiteral(node.argumentExpression) &&
|
||||
legacyNames.has(node.argumentExpression.text)
|
||||
) {
|
||||
violations.push({
|
||||
line: toLine(sourceFile, node.argumentExpression),
|
||||
reason: `references ${subject} "${node.argumentExpression.text}"`,
|
||||
});
|
||||
addViolation(node.argumentExpression, "references", node.argumentExpression.text);
|
||||
}
|
||||
|
||||
if (ts.isCallExpression(node)) {
|
||||
@@ -365,10 +375,7 @@ function findNamedBoundaryViolations(content, fileName, legacyNames, subject) {
|
||||
legacyNames.has(calleeName) &&
|
||||
ts.isIdentifier(unwrapExpression(node.expression))
|
||||
) {
|
||||
violations.push({
|
||||
line: toLine(sourceFile, node.expression),
|
||||
reason: `calls ${subject} "${calleeName}"`,
|
||||
});
|
||||
addViolation(node.expression, "calls", calleeName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -462,6 +469,15 @@ export function findSessionAccessorBoundaryViolations(content, fileName = "sourc
|
||||
return findNamedSessionStoreViolations(content, fileName, legacyNames, legacyKind);
|
||||
}
|
||||
|
||||
export function findReadOnlySessionAccessorViolations(content, fileName = "source.ts") {
|
||||
return findNamedBoundaryViolations(
|
||||
content,
|
||||
fileName,
|
||||
materializingSessionEntryAccessorNames,
|
||||
"materializing session entry accessor",
|
||||
);
|
||||
}
|
||||
|
||||
export function findEmbeddedAgentSessionTargetViolations(content, fileName = "source.ts") {
|
||||
const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true);
|
||||
const violations = findNamedBoundaryViolations(
|
||||
@@ -481,7 +497,10 @@ export function findEmbeddedAgentSessionTargetViolations(content, fileName = "so
|
||||
|
||||
const visitRunOptions = (options) => {
|
||||
for (const property of options.properties) {
|
||||
if (ts.isPropertyAssignment(property) && propertyNameText(property.name) === "sessionFile") {
|
||||
if (
|
||||
ts.isPropertyAssignment(property) &&
|
||||
getPropertyNameText(property.name) === "sessionFile"
|
||||
) {
|
||||
recordDeprecatedSessionFile(property.name);
|
||||
} else if (
|
||||
ts.isShorthandPropertyAssignment(property) &&
|
||||
@@ -610,39 +629,27 @@ const transcriptWriterSourceRootPaths = [
|
||||
"src/sessions",
|
||||
];
|
||||
|
||||
function declarationName(node) {
|
||||
if (ts.isFunctionDeclaration(node) && node.name) {
|
||||
return node.name.text;
|
||||
}
|
||||
if (!ts.isVariableStatement(node)) {
|
||||
return null;
|
||||
}
|
||||
const declaration = node.declarationList.declarations[0];
|
||||
return declaration && ts.isIdentifier(declaration.name) ? declaration.name.text : null;
|
||||
}
|
||||
|
||||
function functionBodyForDeclaration(node) {
|
||||
if (ts.isFunctionDeclaration(node)) {
|
||||
return node.body ?? null;
|
||||
}
|
||||
if (!ts.isVariableStatement(node)) {
|
||||
return null;
|
||||
}
|
||||
const declaration = node.declarationList.declarations[0];
|
||||
const initializer = declaration?.initializer;
|
||||
if (initializer && (ts.isArrowFunction(initializer) || ts.isFunctionExpression(initializer))) {
|
||||
return initializer.body;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function collectTopLevelFunctionBodies(sourceFile) {
|
||||
const bodies = new Map();
|
||||
for (const statement of sourceFile.statements) {
|
||||
const name = declarationName(statement);
|
||||
const body = functionBodyForDeclaration(statement);
|
||||
if (name && body) {
|
||||
bodies.set(name, body);
|
||||
if (ts.isFunctionDeclaration(statement)) {
|
||||
if (statement.name && statement.body) {
|
||||
bodies.set(statement.name.text, statement.body);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!ts.isVariableStatement(statement)) {
|
||||
continue;
|
||||
}
|
||||
const declaration = statement.declarationList.declarations[0];
|
||||
const initializer = declaration?.initializer;
|
||||
if (
|
||||
declaration &&
|
||||
ts.isIdentifier(declaration.name) &&
|
||||
initializer &&
|
||||
(ts.isArrowFunction(initializer) || ts.isFunctionExpression(initializer))
|
||||
) {
|
||||
bodies.set(declaration.name.text, initializer.body);
|
||||
}
|
||||
}
|
||||
return bodies;
|
||||
@@ -839,6 +846,27 @@ async function writeSessionAccessorDebtBaseline(repoRoot) {
|
||||
await fs.writeFile(resolveDebtBaselinePath(repoRoot), `${JSON.stringify(counts, null, 2)}\n`);
|
||||
}
|
||||
|
||||
function compareMigratedFilePaths(left, right, sourceRootPaths) {
|
||||
const rootIndex = (filePath) =>
|
||||
sourceRootPaths.findIndex((sourceRoot) => filePath.startsWith(`${sourceRoot}/`));
|
||||
const leftRoot = rootIndex(left);
|
||||
const rightRoot = rootIndex(right);
|
||||
const rootOrder =
|
||||
(leftRoot < 0 ? sourceRootPaths.length : leftRoot) -
|
||||
(rightRoot < 0 ? sourceRootPaths.length : rightRoot);
|
||||
if (rootOrder !== 0) {
|
||||
return rootOrder;
|
||||
}
|
||||
// Recursive directory walks visit nested files before same-prefix sibling files.
|
||||
const leftTraversalPath = left.replaceAll("/", "\0");
|
||||
const rightTraversalPath = right.replaceAll("/", "\0");
|
||||
return leftTraversalPath < rightTraversalPath
|
||||
? -1
|
||||
: leftTraversalPath > rightTraversalPath
|
||||
? 1
|
||||
: 0;
|
||||
}
|
||||
|
||||
export async function main() {
|
||||
const repoRoot = resolveRepoRoot(import.meta.url);
|
||||
if (process.argv.includes("--update-debt-baseline")) {
|
||||
@@ -846,100 +874,52 @@ export async function main() {
|
||||
console.log(`Wrote ${sessionAccessorDebtBaselineRelativePath}`);
|
||||
return;
|
||||
}
|
||||
const readSourceRoots = resolveSourceRoots(repoRoot, readSourceRootPaths);
|
||||
const writeSourceRoots = resolveSourceRoots(repoRoot, writeSourceRootPaths);
|
||||
const transcriptWriterSourceRoots = resolveSourceRoots(repoRoot, transcriptWriterSourceRootPaths);
|
||||
const readViolations = await collectFileViolations({
|
||||
repoRoot,
|
||||
sourceRoots: readSourceRoots,
|
||||
skipFile: (filePath) => {
|
||||
const relativePath = normalizeRelativePath(path.relative(repoRoot, filePath));
|
||||
return (
|
||||
!migratedSessionAccessorFiles.has(relativePath) &&
|
||||
!migratedBundledPluginSessionAccessorFiles.has(relativePath)
|
||||
);
|
||||
const debtConcerns = Object.fromEntries(
|
||||
sessionAccessorDebtConcerns.map((concern) => [concern.key, concern]),
|
||||
);
|
||||
const enforcementConcerns = [
|
||||
debtConcerns.sessionAccessorRead,
|
||||
debtConcerns.sessionAccessorWrite,
|
||||
debtConcerns.transcriptWriter,
|
||||
{
|
||||
sourceRootPaths: ["src/gateway/server-methods"],
|
||||
migratedFiles: new Set(["src/gateway/server-methods/sessions-create.ts"]),
|
||||
findViolations: findGatewaySessionCreateLifecycleViolations,
|
||||
},
|
||||
findViolations: findSessionAccessorBoundaryViolations,
|
||||
});
|
||||
const writeViolations = await collectFileViolations({
|
||||
repoRoot,
|
||||
sourceRoots: writeSourceRoots,
|
||||
skipFile: (filePath) =>
|
||||
!migratedSessionAccessorWriteFiles.has(
|
||||
normalizeRelativePath(path.relative(repoRoot, filePath)),
|
||||
),
|
||||
findViolations: findSessionAccessorWriteBoundaryViolations,
|
||||
});
|
||||
const transcriptWriterViolations = await collectFileViolations({
|
||||
repoRoot,
|
||||
sourceRoots: transcriptWriterSourceRoots,
|
||||
skipFile: (filePath) =>
|
||||
!migratedTranscriptWriterFiles.has(normalizeRelativePath(path.relative(repoRoot, filePath))),
|
||||
findViolations: findTranscriptWriterBoundaryViolations,
|
||||
});
|
||||
const sessionCreateLifecycleViolations = await collectFileViolations({
|
||||
repoRoot,
|
||||
sourceRoots: resolveSourceRoots(repoRoot, ["src/gateway/server-methods"]),
|
||||
skipFile: (filePath) =>
|
||||
normalizeRelativePath(path.relative(repoRoot, filePath)) !==
|
||||
"src/gateway/server-methods/sessions-create.ts",
|
||||
findViolations: findGatewaySessionCreateLifecycleViolations,
|
||||
});
|
||||
const manualCompactTrimViolations = await collectFileViolations({
|
||||
repoRoot,
|
||||
sourceRoots: resolveSourceRoots(repoRoot, ["src/gateway/server-methods"]),
|
||||
skipFile: (filePath) =>
|
||||
!migratedSessionCompactManualTrimFiles.has(
|
||||
normalizeRelativePath(path.relative(repoRoot, filePath)),
|
||||
),
|
||||
findViolations: findSessionCompactManualTrimBoundaryViolations,
|
||||
});
|
||||
const lifecycleCleanupViolations = await collectFileViolations({
|
||||
repoRoot,
|
||||
sourceRoots: readSourceRoots,
|
||||
skipFile: (filePath) =>
|
||||
!migratedSessionLifecycleCleanupFiles.has(
|
||||
normalizeRelativePath(path.relative(repoRoot, filePath)),
|
||||
),
|
||||
findViolations: findSessionLifecycleCleanupBoundaryViolations,
|
||||
});
|
||||
const memoryHostSessionCorpusViolations = await collectFileViolations({
|
||||
repoRoot,
|
||||
sourceRoots: resolveSourceRoots(repoRoot, ["packages/memory-host-sdk/src/host"]),
|
||||
skipFile: (filePath) =>
|
||||
!migratedMemoryHostSessionCorpusFiles.has(
|
||||
normalizeRelativePath(path.relative(repoRoot, filePath)),
|
||||
),
|
||||
findViolations: findMemoryHostSessionCorpusBoundaryViolations,
|
||||
});
|
||||
const embeddedAgentSessionTargetViolations = await collectFileViolations({
|
||||
repoRoot,
|
||||
sourceRoots: resolveSourceRoots(repoRoot, ["extensions/voice-call/src"]),
|
||||
skipFile: (filePath) =>
|
||||
!migratedEmbeddedAgentSessionTargetFiles.has(
|
||||
normalizeRelativePath(path.relative(repoRoot, filePath)),
|
||||
),
|
||||
findViolations: findEmbeddedAgentSessionTargetViolations,
|
||||
});
|
||||
debtConcerns.sessionCompactManualTrim,
|
||||
debtConcerns.sessionLifecycleCleanup,
|
||||
debtConcerns.memoryHostSessionCorpus,
|
||||
debtConcerns.embeddedAgentSessionTarget,
|
||||
{
|
||||
sourceRootPaths: ["src/gateway"],
|
||||
migratedFiles: readOnlyGatewaySessionAccessorFiles,
|
||||
findViolations: findReadOnlySessionAccessorViolations,
|
||||
},
|
||||
];
|
||||
const violations = [];
|
||||
for (const concern of enforcementConcerns) {
|
||||
violations.push(
|
||||
...(await collectFileViolations({
|
||||
repoRoot,
|
||||
sourceRoots: resolveSourceRoots(
|
||||
repoRoot,
|
||||
[...concern.migratedFiles].toSorted((left, right) =>
|
||||
compareMigratedFilePaths(left, right, concern.sourceRootPaths),
|
||||
),
|
||||
),
|
||||
findViolations: concern.findViolations,
|
||||
})),
|
||||
);
|
||||
}
|
||||
const sessionStoreRuntimePath = path.join(repoRoot, "src/plugin-sdk/session-store-runtime.ts");
|
||||
const sessionStoreRuntimeCompatViolations =
|
||||
findSessionStoreRuntimeFileBackedCompatExportViolations(
|
||||
violations.push(
|
||||
...findSessionStoreRuntimeFileBackedCompatExportViolations(
|
||||
await fs.readFile(sessionStoreRuntimePath, "utf8"),
|
||||
sessionStoreRuntimePath,
|
||||
).map((violation) =>
|
||||
Object.assign({ path: "src/plugin-sdk/session-store-runtime.ts" }, violation),
|
||||
);
|
||||
const violations = [
|
||||
...readViolations,
|
||||
...writeViolations,
|
||||
...transcriptWriterViolations,
|
||||
...sessionCreateLifecycleViolations,
|
||||
...manualCompactTrimViolations,
|
||||
...lifecycleCleanupViolations,
|
||||
...memoryHostSessionCorpusViolations,
|
||||
...embeddedAgentSessionTargetViolations,
|
||||
...sessionStoreRuntimeCompatViolations,
|
||||
];
|
||||
),
|
||||
);
|
||||
|
||||
const baselineCounts = await readSessionAccessorDebtBaseline(repoRoot);
|
||||
if (!baselineCounts) {
|
||||
|
||||
@@ -70,7 +70,7 @@ const gatewaySessionServerMethodFiles = [
|
||||
];
|
||||
|
||||
export const migratedSessionTranscriptReaderFiles = new Set([
|
||||
"src/agents/main-session-restart-recovery.ts",
|
||||
"src/agents/main-session-restart-recovery-store.ts",
|
||||
"src/agents/subagent-announce-output.test.ts",
|
||||
"src/agents/subagent-announce-output.ts",
|
||||
"src/agents/subagent-announce.runtime.ts",
|
||||
|
||||
@@ -7,7 +7,7 @@ import { mkdtempSync, readdirSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
const ACTIONLINT_VERSION = "1.7.11";
|
||||
const ACTIONLINT_VERSION = "1.7.12";
|
||||
const PRE_COMMIT_VERSION = "4.2.0";
|
||||
const WORKFLOW_DIR = ".github/workflows";
|
||||
|
||||
|
||||
+2
-1
@@ -87,6 +87,7 @@ export async function main(argv = process.argv.slice(2)) {
|
||||
parallel: true,
|
||||
commands: [
|
||||
{ name: "conflict markers", args: ["check:no-conflict-markers"] },
|
||||
{ name: "environment variable count ratchet", args: ["check:env-var-count"] },
|
||||
{ name: "max-lines suppression ratchet", args: ["check:max-lines-ratchet"] },
|
||||
{ name: "changelog attributions", args: ["check:changelog-attributions"] },
|
||||
{ name: "database-first legacy-store guard", args: ["check:database-first-legacy-stores"] },
|
||||
@@ -108,7 +109,7 @@ export async function main(argv = process.argv.slice(2)) {
|
||||
{ name: "host env policy", args: ["check:host-env-policy:swift"] },
|
||||
{ name: "opengrep rule metadata", args: ["check:opengrep-rule-metadata"] },
|
||||
{ name: "duplicate scan target coverage", args: ["dup:check:coverage"] },
|
||||
{ name: "npm shrinkwrap guard", args: ["deps:shrinkwrap:check"] },
|
||||
{ name: "npm package-lock guard", args: ["deps:npm-lock:check"] },
|
||||
{ name: "package patch guard", args: ["deps:patches:check"] },
|
||||
{ name: "script declaration contracts", args: ["check:script-declarations"] },
|
||||
],
|
||||
|
||||
@@ -44,35 +44,37 @@ const MACOS_NATIVE_RE =
|
||||
const MACOS_SCRIPT_SCOPE_RE =
|
||||
/^(?:scripts\/(?:check-swift-tools|codesign-mac-app|create-dmg|format-swift|install-swift-tools|install-xcodegen|lint-swift|notarize-mac-artifact|package-mac-app|package-mac-dist)\.sh|scripts\/lib\/(?:plistbuddy|swift-toolchain)\.sh|test\/scripts\/(?:codesign-mac-app|create-dmg|notarize-mac-artifact|package-mac-app|package-mac-dist)\.test\.ts)$/;
|
||||
const IOS_BUILD_RE =
|
||||
/^(apps\/ios\/|apps\/shared\/|apps\/swabble\/|Swabble\/|scripts\/(?:check-swift-tools|format-swift|install-swift-tools|install-xcodegen|lint-swift)\.sh$|scripts\/(?:ios-(?:configure-signing|team-id|write-version-xcconfig)\.sh|ios-write-swift-filelist\.mjs|ios-version\.ts)$|scripts\/lib\/(?:ios-version\.ts|npm-publish-plan\.mjs|version-script-args\.ts)$)/;
|
||||
/^(apps\/ios\/|apps\/shared\/|apps\/swabble\/|Swabble\/|scripts\/(?:check-swift-tools|format-swift|install-swift-tools|install-xcodegen|lint-swift)\.sh$|scripts\/(?:ios-(?:configure-signing|team-id|write-version-xcconfig)\.sh|ios-write-swift-filelist\.mjs|ios-version\.ts)$|scripts\/lib\/(?:ios-version\.ts|release-version\.mjs|version-script-args\.ts)$)/;
|
||||
const ANDROID_NATIVE_RE = /^(apps\/android\/|apps\/shared\/)/;
|
||||
const NODE_SCOPE_RE =
|
||||
/^(src\/|test\/|extensions\/|packages\/|scripts\/|ui\/|\.github\/|openclaw\.mjs$|package\.json$|pnpm-lock\.yaml$|pnpm-workspace\.yaml$|tsconfig.*\.json$|vitest.*\.ts$|tsdown\.config\.ts$|\.oxlintrc\.json$|\.oxfmtrc\.jsonc$)/;
|
||||
const WINDOWS_SQLITE_SCOPE_RE = /^src\/(?:state\/|.*sqlite.*\.ts$)/;
|
||||
const WINDOWS_SCOPE_RE =
|
||||
/^(src\/config\/sessions\/(?:session-accessor\.sqlite-archive|store\.session-lifecycle-mutation\.test)\.ts$|src\/process\/|src\/infra\/windows-install-roots\.ts$|src\/shared\/(?:import-specifier|runtime-import)(?:\.test)?\.ts$|scripts\/(?:install\.ps1|openclaw-cross-os-release-checks\.ts|github\/run-openclaw-cross-os-release-checks\.sh|(?:npm-runner|pnpm-runner|ui|vitest-process-group)\.(?:mjs|js)|lib\/(?:format-generated-module\.mjs|cross-os-release-checks\/[^/]+\.ts))$|test\/scripts\/(?:format-generated-module|install-ps1|npm-runner|openclaw-cross-os-release-workflow|pnpm-runner|ui|vitest-process-group)\.test\.ts$|package\.json$|pnpm-lock\.yaml$|pnpm-workspace\.yaml$|\.github\/workflows\/(?:ci|openclaw-cross-os-release-checks-reusable)\.yml$|\.github\/actions\/setup-node-env\/action\.yml$|\.github\/actions\/setup-pnpm-store-cache\/action\.yml$)/;
|
||||
/^(extensions\/mxc\/|src\/agents\/(?:bash-tools\.exec-script-(?:preflight|target)|bash-tools\.exec\.script-preflight\.test)\.ts$|src\/config\/sessions\/(?:session-accessor\.sqlite-archive|store\.session-lifecycle-mutation\.test)\.ts$|src\/process\/|src\/infra\/(?:(?:exec-allowlist-pattern|fs-safe-remove)(?:\.test)?|ssh-client(?:\.windows\.test)?|update-managed-service-handoff(?:\.test)?|windows-install-roots)\.ts$|src\/shared\/(?:import-specifier|runtime-import)(?:\.test)?\.ts$|src\/test-utils\/openclaw-test-state(?:\.test)?\.ts$|scripts\/(?:install\.ps1|openclaw-cross-os-release-checks\.ts|github\/run-openclaw-cross-os-release-checks\.sh|(?:npm-runner|pnpm-runner|ui|vitest-process-group)\.(?:mjs|js)|lib\/(?:format-generated-module\.mjs|cross-os-release-checks\/[^/]+\.ts))$|test\/scripts\/(?:format-generated-module|install-ps1|npm-runner|openclaw-cross-os-release-workflow|pnpm-runner|ui|vitest-process-group)\.test\.ts$|package\.json$|pnpm-lock\.yaml$|pnpm-workspace\.yaml$|\.github\/workflows\/(?:ci|openclaw-cross-os-release-checks-reusable)\.yml$|\.github\/actions\/setup-node-env\/action\.yml$|\.github\/actions\/setup-pnpm-store-cache\/action\.yml$)/;
|
||||
const WINDOWS_TEST_SCOPE_RE =
|
||||
/^(src\/config\/sessions\/store\.session-lifecycle-mutation\.test\.ts$|src\/process\/(?:exec\.windows|windows-command)\.test\.ts$|src\/infra\/windows-install-roots\.test\.ts$|src\/shared\/runtime-import\.test\.ts$|test\/scripts\/(?:format-generated-module|npm-runner|openclaw-cross-os-release-workflow|pnpm-runner|ui|vitest-process-group)\.test\.ts$)/;
|
||||
/^(extensions\/mxc\/test\/(?:mxc-backend|sandbox-policy-loader)\.test\.ts$|src\/agents\/bash-tools\.exec\.script-preflight\.test\.ts$|src\/config\/sessions\/store\.session-lifecycle-mutation\.test\.ts$|src\/process\/(?:exec\.windows|windows-command)\.test\.ts$|src\/infra\/(?:exec-allowlist-pattern|fs-safe-remove|ssh-client\.windows|update-managed-service-handoff|windows-install-roots)\.test\.ts$|src\/shared\/runtime-import\.test\.ts$|src\/state\/openclaw-database-paths\.windows\.test\.ts$|src\/test-utils\/openclaw-test-state\.test\.ts$|test\/scripts\/(?:format-generated-module|npm-runner|openclaw-cross-os-release-workflow|pnpm-runner|ui|vitest-process-group)\.test\.ts$)/;
|
||||
const WINDOWS_DAEMON_SCOPE_RE =
|
||||
/^src\/daemon\/(?:schtasks(?:[-.][^/]+)?|runtime-hints\.windows-paths(?:\.test)?|test-helpers\/schtasks-(?:base-mocks|fixtures))\.ts$/;
|
||||
const CONTROL_UI_I18N_SCOPE_RE =
|
||||
/^(ui\/src\/i18n\/|scripts\/(?:control-ui-i18n(?:-verify)?\.ts|lib\/control-ui-i18n-(?:config|raw-copy)\.ts)$|\.github\/workflows\/control-ui-locale-refresh\.yml$)/;
|
||||
const CONTROL_UI_RAW_COPY_SOURCE_RE = /^ui\/src\/(?:app|components|lib|pages)\/.*\.tsx?$/;
|
||||
const CONTROL_UI_HARD_GENERATED_I18N_RE =
|
||||
/^(?:ui\/src\/i18n\/locales\/(?!en(?:-agents)?\.ts$)[^/]+\.ts|ui\/src\/i18n\/\.i18n\/(?:catalog-fallbacks\.json|[^/]+\.(?:meta\.json|tm\.jsonl)))$/;
|
||||
const RELEASE_BRANCH_RE = /^release\/\d{4}\.\d+\.\d+$/;
|
||||
|
||||
export class ControlUiGeneratedArtifactsMixedError extends Error {}
|
||||
export class NativeGeneratedArtifactsMixedError extends Error {}
|
||||
const CONTROL_UI_TEST_SCOPE_RE =
|
||||
/^(ui\/|test\/vitest\/vitest\.shared\.config\.ts$|scripts\/ensure-playwright-chromium\.mjs$)/;
|
||||
const CHROMIUM_UI_TEST_SCOPE_RE =
|
||||
/^(ui\/|extensions\/browser\/chrome-extension\/|test\/vitest\/vitest\.(?:shared|ui-e2e)\.config\.ts$|scripts\/ensure-playwright-chromium\.mjs$|package\.json$|\.github\/workflows\/ci\.yml$)/;
|
||||
const NATIVE_I18N_SCOPE_RE =
|
||||
/^(?:apps\/\.i18n\/|apps\/android\/app\/src\/main\/|apps\/ios\/|apps\/macos\/Sources\/|apps\/shared\/OpenClawKit\/Sources\/|scripts\/(?:android-app-i18n|apple-app-i18n|native-app-i18n)\.ts$|test\/scripts\/(?:android-app-i18n|apple-app-i18n|native-app-i18n)\.test\.ts$|\.github\/workflows\/(?:ci|native-app-locale-refresh)\.yml$)/;
|
||||
/^(?:apps\/\.i18n\/|apps\/android\/(?:app\/src\/(?:main|play|thirdParty)\/|wear\/src\/main\/)|apps\/ios\/|apps\/macos\/Sources\/|apps\/shared\/OpenClawKit\/Sources\/|scripts\/(?:android-app-i18n|apple-app-i18n|native-app-i18n)\.ts$|test\/scripts\/(?:android-app-i18n|apple-app-i18n|native-app-i18n)\.test\.ts$|\.github\/workflows\/(?:ci|native-app-locale-refresh)\.yml$)/;
|
||||
// Android base resources are co-owned: source PRs edit their English content,
|
||||
// while the generator rewrites managed sections. Treat them as generated only
|
||||
// alongside a hard-generated artifact so neither ownership path blocks the other.
|
||||
const NATIVE_COOWNED_GENERATED_I18N_RE =
|
||||
/^apps\/android\/app\/src\/main\/res\/values\/(?:assistant|strings)\.xml$/;
|
||||
const NATIVE_HARD_GENERATED_I18N_RE =
|
||||
/^(?:apps\/\.i18n\/native\/[^/]+\.json|apps\/\.i18n\/apple-translation-contradictions\.json|apps\/android\/app\/src\/main\/java\/ai\/openclaw\/app\/i18n\/NativeStringResources\.kt|apps\/android\/app\/src\/main\/res\/values-[^/]+\/(?:assistant|strings)\.xml|apps\/ios\/Resources\/Localizable\.xcstrings|apps\/ios\/(?:Sources|WatchApp|ShareExtension|ActivityWidget)\/[^/]+\.lproj\/InfoPlist\.strings)$/;
|
||||
/^(?:apps\/\.i18n\/native\/[^/]+\.json|apps\/\.i18n\/apple-translation-contradictions\.json|apps\/android\/app\/src\/main\/java\/ai\/openclaw\/app\/i18n\/NativeStringResources\.kt|apps\/android\/app\/src\/main\/res\/values-[^/]+\/(?:assistant|strings)\.xml|apps\/android\/app\/src\/thirdParty\/res\/values-[^/]+\/accessibility_strings\.xml|apps\/android\/wear\/src\/main\/res\/values-[^/]+\/strings\.xml|apps\/ios\/Resources\/Localizable\.xcstrings|apps\/macos\/Sources\/OpenClaw\/Resources\/Localizable\.xcstrings|apps\/ios\/(?:Sources|WatchApp|ShareExtension|ActivityWidget)\/[^/]+\.lproj\/InfoPlist\.strings)$/;
|
||||
const FAST_INSTALL_SMOKE_SCOPE_RE =
|
||||
/^(Dockerfile$|\.npmrc$|package\.json$|pnpm-lock\.yaml$|pnpm-workspace\.yaml$|scripts\/ci-changed-scope\.mjs$|scripts\/postinstall-bundled-plugins\.mjs$|scripts\/e2e\/(?:Dockerfile(?:\.qr-import)?|agents-delete-shared-workspace-docker\.sh|gateway-network-docker\.sh)$|extensions\/[^/]+\/(?:package\.json|openclaw\.plugin\.json)$|\.github\/workflows\/install-smoke\.yml$|\.github\/actions\/setup-node-env\/action\.yml$)/;
|
||||
const FULL_INSTALL_SMOKE_SCOPE_RE =
|
||||
@@ -154,7 +156,9 @@ export function detectChangedScope(changedPaths) {
|
||||
}
|
||||
|
||||
if (
|
||||
(WINDOWS_SCOPE_RE.test(path) || WINDOWS_DAEMON_SCOPE_RE.test(path)) &&
|
||||
(WINDOWS_SCOPE_RE.test(path) ||
|
||||
WINDOWS_SQLITE_SCOPE_RE.test(path) ||
|
||||
WINDOWS_DAEMON_SCOPE_RE.test(path)) &&
|
||||
(!facts.isTestOnly || WINDOWS_TEST_SCOPE_RE.test(path) || WINDOWS_DAEMON_SCOPE_RE.test(path))
|
||||
) {
|
||||
runWindows = true;
|
||||
@@ -164,11 +168,14 @@ export function detectChangedScope(changedPaths) {
|
||||
runChangedSmoke = true;
|
||||
}
|
||||
|
||||
if (CONTROL_UI_I18N_SCOPE_RE.test(path)) {
|
||||
if (
|
||||
CONTROL_UI_I18N_SCOPE_RE.test(path) ||
|
||||
(CONTROL_UI_RAW_COPY_SOURCE_RE.test(path) && !facts.isTestOnly)
|
||||
) {
|
||||
runControlUiI18n = true;
|
||||
}
|
||||
|
||||
if (CONTROL_UI_TEST_SCOPE_RE.test(path)) {
|
||||
if (CHROMIUM_UI_TEST_SCOPE_RE.test(path)) {
|
||||
runUiTests = true;
|
||||
}
|
||||
|
||||
|
||||
@@ -101,7 +101,7 @@ function isPnpmStoreWarmupGatedJobName(name) {
|
||||
name === "build-artifacts" ||
|
||||
name === "check-docs" ||
|
||||
name === "check-guards" ||
|
||||
name === "check-shrinkwrap" ||
|
||||
name === "check-npm-lock" ||
|
||||
name === "check-prod-types" ||
|
||||
name === "check-lint" ||
|
||||
name === "check-dependencies" ||
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"atalovesyou",
|
||||
"0xJonHoldsCrypto",
|
||||
"hougangdev",
|
||||
"jiulingyun"
|
||||
"mf-yang"
|
||||
],
|
||||
"seedCommit": "d6863f87",
|
||||
"displayName": {
|
||||
@@ -33,6 +33,7 @@
|
||||
"emailToLogin": {
|
||||
"123guan@gmail.com": "guanbear",
|
||||
"guanbear@macmini.bearhome": "guanbear",
|
||||
"shawn.duggan@gmail.com": "shawnduggan",
|
||||
"steipete@gmail.com": "steipete",
|
||||
"sbarrios93@gmail.com": "sebslight",
|
||||
"rltorres26+github@gmail.com": "RandyVentures",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,9 +2,73 @@ function historyMessage(role: "assistant" | "user", text: string, timestamp: num
|
||||
return { content: [{ type: "text", text }], role, timestamp };
|
||||
}
|
||||
|
||||
function finishedTask(n: number, now: number) {
|
||||
const task = {
|
||||
id: `task-mock-finished-${n}`,
|
||||
taskId: `task-mock-finished-${n}`,
|
||||
status: n === 3 ? "failed" : "completed",
|
||||
runtime: "subagent",
|
||||
agentId: "openclaw-mock",
|
||||
title: `Finished mock task number ${n} with a fairly long title`,
|
||||
createdAt: now - n * 600_000,
|
||||
startedAt: now - n * 600_000,
|
||||
endedAt: now - n * 500_000,
|
||||
updatedAt: now - n * 500_000,
|
||||
};
|
||||
return n === 3
|
||||
? { ...task, error: "Mock task stopped after finding an invalid event scope." }
|
||||
: { ...task, terminalSummary: `Mock task ${n} completed its assigned inspection.` };
|
||||
}
|
||||
|
||||
function taskDetailCase(task: { id: string; title: string } & Record<string, unknown>) {
|
||||
return {
|
||||
match: { taskId: task.id },
|
||||
response: {
|
||||
task: {
|
||||
...task,
|
||||
prompt: `Inspect ${task.title.toLowerCase()} and report the current execution path.`,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function buildBackgroundTasksMock(baseTime: number) {
|
||||
const now = Date.now();
|
||||
const taskSessionKey = "agent:openclaw-mock:subagent:mock-task-1";
|
||||
const tasks = [
|
||||
{
|
||||
id: "task-mock-running",
|
||||
taskId: "task-mock-running",
|
||||
status: "running",
|
||||
runtime: "subagent",
|
||||
agentId: "openclaw-mock",
|
||||
title: "Map run-status indicator code",
|
||||
createdAt: now - 25_000,
|
||||
startedAt: now - 25_000,
|
||||
updatedAt: now,
|
||||
toolUseCount: 7,
|
||||
lastToolName: "read",
|
||||
progressSummary: "Tracing task events through the background task rail",
|
||||
childSessionKey: taskSessionKey,
|
||||
},
|
||||
{
|
||||
id: "task-mock-running-2",
|
||||
taskId: "task-mock-running-2",
|
||||
status: "running",
|
||||
runtime: "subagent",
|
||||
agentId: "openclaw-mock",
|
||||
title: "Audit gateway event scope guards",
|
||||
createdAt: now - 95_000,
|
||||
startedAt: now - 95_000,
|
||||
updatedAt: now - 1_000,
|
||||
progressSummary: "Comparing agent-scoped task event paths",
|
||||
},
|
||||
finishedTask(1, now),
|
||||
finishedTask(2, now),
|
||||
finishedTask(3, now),
|
||||
finishedTask(4, now),
|
||||
finishedTask(5, now),
|
||||
];
|
||||
return {
|
||||
"chat.history": {
|
||||
cases: [
|
||||
@@ -30,46 +94,7 @@ export function buildBackgroundTasksMock(baseTime: number) {
|
||||
],
|
||||
},
|
||||
// One live subagent task exercises the rail, collapsed badge, and running-task status row.
|
||||
"tasks.list": {
|
||||
tasks: [
|
||||
{
|
||||
id: "task-mock-running",
|
||||
taskId: "task-mock-running",
|
||||
status: "running",
|
||||
runtime: "subagent",
|
||||
agentId: "openclaw-mock",
|
||||
title: "Map run-status indicator code",
|
||||
createdAt: now - 25_000,
|
||||
startedAt: now - 25_000,
|
||||
updatedAt: now,
|
||||
toolUseCount: 7,
|
||||
lastToolName: "read",
|
||||
childSessionKey: taskSessionKey,
|
||||
},
|
||||
{
|
||||
id: "task-mock-running-2",
|
||||
taskId: "task-mock-running-2",
|
||||
status: "running",
|
||||
runtime: "subagent",
|
||||
agentId: "openclaw-mock",
|
||||
title: "Audit gateway event scope guards",
|
||||
createdAt: now - 95_000,
|
||||
startedAt: now - 95_000,
|
||||
updatedAt: now - 1_000,
|
||||
},
|
||||
...[1, 2, 3, 4, 5].map((n) => ({
|
||||
id: `task-mock-finished-${n}`,
|
||||
taskId: `task-mock-finished-${n}`,
|
||||
status: n === 3 ? "failed" : "completed",
|
||||
runtime: "subagent",
|
||||
agentId: "openclaw-mock",
|
||||
title: `Finished mock task number ${n} with a fairly long title`,
|
||||
createdAt: now - n * 600_000,
|
||||
startedAt: now - n * 600_000,
|
||||
endedAt: now - n * 500_000,
|
||||
updatedAt: now - n * 500_000,
|
||||
})),
|
||||
],
|
||||
},
|
||||
"tasks.list": { tasks },
|
||||
"tasks.get": { cases: tasks.map(taskDetailCase) },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -76,6 +76,59 @@ export function buildChannelsStatusMock(baseTime: number) {
|
||||
};
|
||||
}
|
||||
|
||||
export function buildChannelsPairingMock(baseTime: number) {
|
||||
const iso = (offsetMs: number) => new Date(baseTime + offsetMs).toISOString();
|
||||
return {
|
||||
accounts: [
|
||||
{
|
||||
channel: "telegram",
|
||||
channelLabel: "Telegram",
|
||||
accountId: "default",
|
||||
accountLabel: "Telegram Bot",
|
||||
notifySupported: true,
|
||||
},
|
||||
{
|
||||
channel: "whatsapp",
|
||||
channelLabel: "WhatsApp",
|
||||
accountId: "default",
|
||||
accountLabel: "WhatsApp Web",
|
||||
notifySupported: false,
|
||||
},
|
||||
],
|
||||
requests: [
|
||||
{
|
||||
requestId: "pairing-req-1",
|
||||
channel: "telegram",
|
||||
channelLabel: "Telegram",
|
||||
accountId: "default",
|
||||
accountLabel: "Telegram Bot",
|
||||
senderId: "552731142",
|
||||
senderLabel: "Mira Delgado (@miradelgado)",
|
||||
metadata: { username: "miradelgado", firstName: "Mira", lastName: "Delgado" },
|
||||
createdAt: iso(-14 * 60_000),
|
||||
lastSeenAt: iso(-2 * 60_000),
|
||||
expiresAt: iso(46 * 60_000),
|
||||
notifySupported: true,
|
||||
},
|
||||
{
|
||||
requestId: "pairing-req-2",
|
||||
channel: "whatsapp",
|
||||
channelLabel: "WhatsApp",
|
||||
accountId: "default",
|
||||
accountLabel: "WhatsApp Web",
|
||||
senderId: "+1 555 0192",
|
||||
senderLabel: "Unknown sender",
|
||||
createdAt: iso(-3 * 60_000),
|
||||
lastSeenAt: iso(-60_000),
|
||||
expiresAt: iso(57 * 60_000),
|
||||
notifySupported: false,
|
||||
},
|
||||
],
|
||||
commandOwnerConfigured: false,
|
||||
limits: { pendingPerAccount: 3, ttlMs: 3_600_000 },
|
||||
};
|
||||
}
|
||||
|
||||
export function buildChannelWizardMocks() {
|
||||
const channelSelectStep = {
|
||||
id: "mock-wizard-step-channel",
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
import type {
|
||||
CronJob,
|
||||
CronJobsListResult,
|
||||
CronRunLogEntry,
|
||||
CronRunsResult,
|
||||
CronStatus,
|
||||
} from "../ui/src/api/types.ts";
|
||||
|
||||
function listResult(
|
||||
jobs: CronJob[],
|
||||
options: { total?: number; limit?: number; offset?: number } = {},
|
||||
): CronJobsListResult {
|
||||
const total = options.total ?? jobs.length;
|
||||
const limit = options.limit ?? 50;
|
||||
const offset = options.offset ?? 0;
|
||||
const nextOffset = offset + jobs.length;
|
||||
const hasMore = nextOffset < total;
|
||||
return {
|
||||
jobs,
|
||||
total,
|
||||
offset,
|
||||
limit,
|
||||
hasMore,
|
||||
nextOffset: hasMore ? nextOffset : null,
|
||||
};
|
||||
}
|
||||
|
||||
function runsResult(entries: CronRunLogEntry[]): CronRunsResult {
|
||||
return {
|
||||
entries,
|
||||
total: entries.length,
|
||||
offset: 0,
|
||||
limit: 50,
|
||||
hasMore: false,
|
||||
nextOffset: null,
|
||||
};
|
||||
}
|
||||
|
||||
function singleJobListCases(jobs: CronJob[], match: Record<string, unknown>) {
|
||||
return jobs.map((job, offset) => ({
|
||||
match: { ...match, offset },
|
||||
response: listResult([job], { total: jobs.length, limit: 1, offset }),
|
||||
}));
|
||||
}
|
||||
|
||||
export function buildCronMocks(baseTime: number) {
|
||||
const minute = 60_000;
|
||||
const hour = 60 * minute;
|
||||
const day = 24 * hour;
|
||||
const failedJob: CronJob = {
|
||||
id: "mock-cron-calendar-sync",
|
||||
agentId: "main",
|
||||
name: "Sync team calendar",
|
||||
description: "Refresh the shared calendar cache for the morning briefing.",
|
||||
enabled: true,
|
||||
createdAtMs: baseTime - 30 * day,
|
||||
updatedAtMs: baseTime - 4 * minute,
|
||||
schedule: { kind: "cron", expr: "0 */6 * * *", tz: "America/New_York" },
|
||||
sessionTarget: "isolated",
|
||||
wakeMode: "now",
|
||||
payload: {
|
||||
kind: "agentTurn",
|
||||
message: "Sync the team calendar and summarize schedule conflicts.",
|
||||
},
|
||||
delivery: { mode: "announce", channel: "telegram", to: "@operations" },
|
||||
state: {
|
||||
nextRunAtMs: baseTime + 5 * hour,
|
||||
lastRunAtMs: baseTime - 5 * minute,
|
||||
lastRunStatus: "error",
|
||||
lastError:
|
||||
"OAuth refresh failed: invalid_grant. The provider rejected the stored refresh token because it was revoked or expired. Reconnect Google Calendar before the next scheduled sync.",
|
||||
lastDurationMs: 8_420,
|
||||
consecutiveErrors: 2,
|
||||
lastDeliveryStatus: "not-requested",
|
||||
},
|
||||
};
|
||||
const overdueJob: CronJob = {
|
||||
id: "mock-cron-inbox-triage",
|
||||
agentId: "main",
|
||||
name: "Triage support inbox",
|
||||
description: "Classify new support mail and prepare the daily response queue.",
|
||||
enabled: true,
|
||||
createdAtMs: baseTime - 18 * day,
|
||||
updatedAtMs: baseTime - 35 * minute,
|
||||
schedule: { kind: "every", everyMs: 15 * minute, anchorMs: baseTime - 18 * day },
|
||||
sessionTarget: "isolated",
|
||||
wakeMode: "now",
|
||||
payload: { kind: "agentTurn", message: "Triage the support inbox." },
|
||||
delivery: { mode: "none" },
|
||||
state: {
|
||||
nextRunAtMs: baseTime - 20 * minute,
|
||||
lastRunAtMs: baseTime - 35 * minute,
|
||||
lastRunStatus: "ok",
|
||||
lastDurationMs: 24_180,
|
||||
lastDeliveryStatus: "not-requested",
|
||||
},
|
||||
};
|
||||
const healthyJob: CronJob = {
|
||||
id: "mock-cron-release-digest",
|
||||
agentId: "main",
|
||||
name: "Publish release digest",
|
||||
description: "Summarize merged changes for the engineering channel.",
|
||||
enabled: true,
|
||||
createdAtMs: baseTime - 9 * day,
|
||||
updatedAtMs: baseTime - 30 * minute,
|
||||
schedule: { kind: "cron", expr: "30 9 * * 1-5", tz: "America/New_York" },
|
||||
sessionTarget: "isolated",
|
||||
wakeMode: "now",
|
||||
payload: { kind: "agentTurn", message: "Draft and publish the release digest." },
|
||||
delivery: { mode: "announce", channel: "slack", to: "#engineering" },
|
||||
state: {
|
||||
nextRunAtMs: baseTime + 30 * minute,
|
||||
lastRunAtMs: baseTime - 30 * minute,
|
||||
lastRunStatus: "ok",
|
||||
lastDurationMs: 51_230,
|
||||
lastDelivered: true,
|
||||
lastDeliveryStatus: "delivered",
|
||||
},
|
||||
};
|
||||
const jobs = [overdueJob, healthyJob, failedJob];
|
||||
const failedRun: CronRunLogEntry = {
|
||||
ts: baseTime - 5 * minute,
|
||||
runAtMs: baseTime - 5 * minute,
|
||||
jobId: failedJob.id,
|
||||
jobName: failedJob.name,
|
||||
action: "finished",
|
||||
status: "error",
|
||||
durationMs: failedJob.state?.lastDurationMs,
|
||||
error: failedJob.state?.lastError,
|
||||
deliveryStatus: "not-requested",
|
||||
model: "gpt-5.6-sol",
|
||||
provider: "openai",
|
||||
};
|
||||
const runs: CronRunLogEntry[] = [
|
||||
failedRun,
|
||||
{
|
||||
ts: baseTime - 30 * minute,
|
||||
runAtMs: baseTime - 30 * minute,
|
||||
jobId: healthyJob.id,
|
||||
jobName: healthyJob.name,
|
||||
action: "finished",
|
||||
status: "ok",
|
||||
durationMs: healthyJob.state?.lastDurationMs,
|
||||
summary: "Published a digest covering 14 merged changes.",
|
||||
delivered: true,
|
||||
deliveryStatus: "delivered",
|
||||
model: "claude-sonnet-4-6",
|
||||
provider: "anthropic",
|
||||
},
|
||||
{
|
||||
ts: baseTime - 35 * minute,
|
||||
runAtMs: baseTime - 35 * minute,
|
||||
jobId: overdueJob.id,
|
||||
jobName: overdueJob.name,
|
||||
action: "finished",
|
||||
status: "ok",
|
||||
durationMs: overdueJob.state?.lastDurationMs,
|
||||
summary: "Classified 23 messages and prepared 6 replies.",
|
||||
deliveryStatus: "not-requested",
|
||||
model: "gpt-5.6-sol",
|
||||
provider: "openai",
|
||||
},
|
||||
];
|
||||
const queuedRuns: Array<{ runId: string; entry: CronRunLogEntry }> = jobs.map((job, index) => ({
|
||||
runId: `mock-cron-manual-${job.id}`,
|
||||
entry: {
|
||||
ts: baseTime + index,
|
||||
runAtMs: baseTime + index,
|
||||
jobId: job.id,
|
||||
jobName: job.name,
|
||||
action: "finished",
|
||||
status: "ok",
|
||||
durationMs: 42_000 + index * 2_500,
|
||||
summary: `Completed an on-demand run for ${job.name}.`,
|
||||
deliveryStatus: "not-requested",
|
||||
model: "gpt-5.6-sol",
|
||||
provider: "openai",
|
||||
},
|
||||
}));
|
||||
const status: CronStatus = {
|
||||
enabled: true,
|
||||
jobs: jobs.length,
|
||||
nextWakeAtMs: overdueJob.state?.nextRunAtMs,
|
||||
};
|
||||
const runByJobId = new Map(runs.map((entry) => [entry.jobId, entry]));
|
||||
const sortedJobLists = [
|
||||
{ match: { sortBy: "nextRunAtMs", sortDir: "asc" }, jobs },
|
||||
{
|
||||
match: { sortBy: "nextRunAtMs", sortDir: "desc" },
|
||||
jobs: [failedJob, healthyJob, overdueJob],
|
||||
},
|
||||
{ match: { sortBy: "updatedAtMs", sortDir: "asc" }, jobs },
|
||||
{
|
||||
match: { sortBy: "updatedAtMs", sortDir: "desc" },
|
||||
jobs: [failedJob, healthyJob, overdueJob],
|
||||
},
|
||||
{ match: { sortBy: "name", sortDir: "asc" }, jobs: [healthyJob, failedJob, overdueJob] },
|
||||
{ match: { sortBy: "name", sortDir: "desc" }, jobs: [overdueJob, failedJob, healthyJob] },
|
||||
];
|
||||
|
||||
return {
|
||||
"cron.status": status,
|
||||
"cron.list": {
|
||||
// Cases mirror the concrete queries today's Cron UI issues. Unknown combinations fall back
|
||||
// to the full fixture list; dynamic evaluation is intentionally out of scope because the
|
||||
// scenario is JSON-serialized into the page rather than installed as a live responder.
|
||||
cases: [
|
||||
{
|
||||
match: { enabled: "enabled", lastRunStatus: "error" },
|
||||
response: listResult([failedJob], { limit: 1 }),
|
||||
},
|
||||
{ match: { enabled: "disabled" }, response: listResult([]) },
|
||||
...singleJobListCases(jobs, {
|
||||
enabled: "enabled",
|
||||
sortBy: "nextRunAtMs",
|
||||
sortDir: "asc",
|
||||
limit: 1,
|
||||
}),
|
||||
...singleJobListCases(jobs, { includeDisabled: true, limit: 1 }),
|
||||
...sortedJobLists.map((entry) => ({
|
||||
match: entry.match,
|
||||
response: listResult(entry.jobs),
|
||||
})),
|
||||
{ response: listResult(jobs) },
|
||||
],
|
||||
},
|
||||
"cron.runs": {
|
||||
cases: [
|
||||
...queuedRuns.map((run) => ({
|
||||
match: { runId: run.runId },
|
||||
response: runsResult([run.entry]),
|
||||
})),
|
||||
...jobs.flatMap((job) => {
|
||||
const jobRun = runByJobId.get(job.id);
|
||||
return [
|
||||
{
|
||||
match: { scope: "job", id: job.id, statuses: ["error"] },
|
||||
response: runsResult(jobRun?.status === "error" ? [jobRun] : []),
|
||||
},
|
||||
{
|
||||
match: { scope: "job", id: job.id },
|
||||
response: runsResult(jobRun ? [jobRun] : []),
|
||||
},
|
||||
];
|
||||
}),
|
||||
{ match: { statuses: ["error"] }, response: runsResult([failedRun]) },
|
||||
{ response: runsResult(runs) },
|
||||
],
|
||||
},
|
||||
// Writes acknowledge the UI action but intentionally keep the fixture snapshot immutable.
|
||||
"cron.add": { id: "mock-cron-created" },
|
||||
"cron.update": { ok: true },
|
||||
"cron.remove": { ok: true },
|
||||
"cron.run": {
|
||||
cases: queuedRuns.map((run) => ({
|
||||
match: { id: run.entry.jobId },
|
||||
response: { ok: true, enqueued: true, runId: run.runId },
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
+766
-48
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@ import {
|
||||
NON_PACKAGED_BUNDLED_PLUGIN_DIRS,
|
||||
} from "./lib/bundled-plugin-build-entries.mjs";
|
||||
import { shouldBuildBundledCluster } from "./lib/optional-bundled-clusters.mjs";
|
||||
import { assertRealOutputRoot } from "./lib/output-root-guard.mjs";
|
||||
import {
|
||||
mergeGeneratedChannelConfigs,
|
||||
readGeneratedBundledChannelConfigs,
|
||||
@@ -246,6 +247,9 @@ export function copyBundledPluginMetadata(params = {}) {
|
||||
if (!fs.existsSync(extensionsRoot)) {
|
||||
return;
|
||||
}
|
||||
// Fail closed before any dist/extensions removal: a symlinked dist root
|
||||
// would redirect recursive deletes into the link target.
|
||||
assertRealOutputRoot(path.join(repoRoot, "dist"));
|
||||
|
||||
const buildablePluginDirs = new Set(
|
||||
collectBundledPluginBuildEntries({ cwd: repoRoot, env }).map((entry) => entry.id),
|
||||
|
||||
@@ -5,20 +5,17 @@
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { ensureDirectory, logVerboseCopy, resolveBuildCopyContext } from "./lib/copy-assets.ts";
|
||||
import { assertRealOutputRoot } from "./lib/output-root-guard.mjs";
|
||||
|
||||
const context = resolveBuildCopyContext(import.meta.url);
|
||||
|
||||
const exportHtmlSrcDir = path.join(
|
||||
context.projectRoot,
|
||||
"src",
|
||||
"auto-reply",
|
||||
"reply",
|
||||
"export-html",
|
||||
);
|
||||
const exportHtmlDistDir = path.join(context.projectRoot, "dist", "export-html");
|
||||
|
||||
function copyExportHtmlTemplates() {
|
||||
export function copyExportHtmlTemplates(params: { projectRoot?: string } = {}) {
|
||||
const projectRoot = params.projectRoot ?? context.projectRoot;
|
||||
const exportHtmlSrcDir = path.join(projectRoot, "src", "auto-reply", "reply", "export-html");
|
||||
const exportHtmlDistDir = path.join(projectRoot, "dist", "export-html");
|
||||
assertRealOutputRoot(path.join(projectRoot, "dist"));
|
||||
if (!fs.existsSync(exportHtmlSrcDir)) {
|
||||
console.warn(`${context.prefix} Source directory not found:`, exportHtmlSrcDir);
|
||||
return;
|
||||
@@ -52,4 +49,6 @@ function copyExportHtmlTemplates() {
|
||||
console.log(`${context.prefix} Copied ${copiedCount} export-html assets.`);
|
||||
}
|
||||
|
||||
copyExportHtmlTemplates();
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
||||
copyExportHtmlTemplates();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
export type CrabboxWorkload =
|
||||
| "ci-fast"
|
||||
| "ci-proof"
|
||||
| "desktop"
|
||||
| "interactive"
|
||||
| "release-proof"
|
||||
| "untrusted"
|
||||
| "windows";
|
||||
|
||||
export type CrabboxProviderReadiness = {
|
||||
ready: boolean;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export function normalizeCrabboxWorkload(value: unknown): CrabboxWorkload | "" | null;
|
||||
|
||||
export function crabboxProviderChain(options: {
|
||||
workload: CrabboxWorkload | "";
|
||||
configuredProvider: string;
|
||||
target: string;
|
||||
advertisedProviders: readonly string[];
|
||||
}): string[];
|
||||
|
||||
export function selectReadyCrabboxProvider<T extends CrabboxProviderReadiness>(
|
||||
chain: readonly string[],
|
||||
readiness: ReadonlyMap<string, T>,
|
||||
): { provider: string; readiness: T } | null;
|
||||
@@ -0,0 +1,70 @@
|
||||
const workloadAliases = new Map([
|
||||
["check", "ci-fast"],
|
||||
["ci", "ci-fast"],
|
||||
["ci-fast", "ci-fast"],
|
||||
["ci-proof", "ci-proof"],
|
||||
["desktop", "desktop"],
|
||||
["interactive", "interactive"],
|
||||
["release", "release-proof"],
|
||||
["release-proof", "release-proof"],
|
||||
["untrusted", "untrusted"],
|
||||
["windows", "windows"],
|
||||
]);
|
||||
|
||||
export function normalizeCrabboxWorkload(value) {
|
||||
const normalized = `${value ?? ""}`.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return "";
|
||||
}
|
||||
return workloadAliases.get(normalized) ?? null;
|
||||
}
|
||||
|
||||
export function crabboxProviderChain({
|
||||
workload,
|
||||
configuredProvider,
|
||||
target,
|
||||
advertisedProviders,
|
||||
}) {
|
||||
const providers = new Set(advertisedProviders);
|
||||
const normalizedConfigured = `${configuredProvider ?? ""}`.trim();
|
||||
const normalizedTarget = `${target ?? ""}`.trim().toLowerCase();
|
||||
|
||||
if (normalizedTarget === "macos") {
|
||||
return available(["aws"], providers);
|
||||
}
|
||||
if (normalizedTarget === "windows" || workload === "windows") {
|
||||
return available(["azure", "aws"], providers);
|
||||
}
|
||||
|
||||
const cloudFallback = ["azure", "aws"];
|
||||
switch (workload) {
|
||||
case "ci-fast":
|
||||
return available(["blacksmith-testbox", "daytona", ...cloudFallback], providers);
|
||||
case "ci-proof":
|
||||
case "release-proof":
|
||||
return available(["blacksmith-testbox", "daytona", ...cloudFallback], providers);
|
||||
case "interactive":
|
||||
return available(["daytona", ...cloudFallback], providers);
|
||||
case "desktop":
|
||||
return available(cloudFallback, providers);
|
||||
case "untrusted":
|
||||
// Daytona remains excluded until its brokered isolation profile has live proof.
|
||||
return available(cloudFallback, providers);
|
||||
default:
|
||||
return available([normalizedConfigured], providers);
|
||||
}
|
||||
}
|
||||
|
||||
export function selectReadyCrabboxProvider(chain, readiness) {
|
||||
for (const provider of chain) {
|
||||
const status = readiness.get(provider);
|
||||
if (status?.ready) {
|
||||
return { provider, readiness: status };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function available(candidates, advertisedProviders) {
|
||||
return candidates.filter((provider) => provider && advertisedProviders.has(provider));
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
set -euo pipefail
|
||||
|
||||
node_version="24.15.0"
|
||||
pnpm_spec="pnpm@11.2.2+sha512.36e6621fad506178936455e70247b8808ef4ec25797a9f437a93281a020484e2607f6a469a22e982987c3dbb8866e3071514ab10a4a1749e06edcd1ec118436f"
|
||||
pnpm_spec="pnpm@11.15.1+sha512.81350b07e53c9538a02f1f2303b4290fa2d7be04e56e2a970c4cc4b417dc761de196edabd49d55c7dc9580db81007c44143e4e3d7e462b3000d23c255122d065"
|
||||
|
||||
if [[ $# -lt 2 ]]; then
|
||||
echo "usage: $0 <expected-head-sha> <command> [args...]" >&2
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
export function canonicalProviderName(provider: unknown): unknown;
|
||||
export function parseProvidersFromHelp(text: unknown): unknown[];
|
||||
export function isProviderAdvertised(provider: unknown, advertisedProviders: unknown): unknown;
|
||||
export function canonicalProviderName(provider: string): string;
|
||||
export function parseProvidersFromHelp(text: string): string[];
|
||||
export function isProviderAdvertised(
|
||||
provider: string,
|
||||
advertisedProviders: readonly string[],
|
||||
): boolean;
|
||||
|
||||
+417
-40
@@ -21,6 +21,7 @@ import { homedir, tmpdir } from "node:os";
|
||||
import { delimiter, dirname, extname, isAbsolute, relative, resolve } from "node:path";
|
||||
import { StringDecoder } from "node:string_decoder";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { crabboxProviderChain, normalizeCrabboxWorkload } from "./crabbox-routing-policy.mjs";
|
||||
import {
|
||||
canonicalProviderName,
|
||||
isProviderAdvertised,
|
||||
@@ -54,11 +55,45 @@ const args = process.argv.slice(2);
|
||||
if (args[0] === "--") {
|
||||
args.shift();
|
||||
}
|
||||
const workloadOption = isWorkloadRoutedCommand(args)
|
||||
? extractWrapperValueOption(args, "--workload")
|
||||
: undefined;
|
||||
const userArgStart = args[0] === "actions" && args[1] === "hydrate" ? 2 : 1;
|
||||
if (args[userArgStart] === "--") {
|
||||
args.splice(userArgStart, 1);
|
||||
}
|
||||
|
||||
function extractWrapperValueOption(commandArgs, name) {
|
||||
const equalsPrefix = `${name}=`;
|
||||
for (let index = 0; index < commandArgs.length; index += 1) {
|
||||
const arg = commandArgs[index];
|
||||
if (arg === "--") {
|
||||
break;
|
||||
}
|
||||
if (arg === name) {
|
||||
const value = commandArgs[index + 1];
|
||||
if (!value || value === "--" || value.startsWith("-")) {
|
||||
commandArgs.splice(index, 1);
|
||||
return null;
|
||||
}
|
||||
commandArgs.splice(index, 2);
|
||||
return value;
|
||||
}
|
||||
if (arg.startsWith(equalsPrefix)) {
|
||||
commandArgs.splice(index, 1);
|
||||
return arg.slice(equalsPrefix.length) || null;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isWorkloadRoutedCommand(commandArgs) {
|
||||
return (
|
||||
["run", "warmup"].includes(commandArgs[0]) ||
|
||||
(commandArgs[0] === "actions" && commandArgs[1] === "hydrate")
|
||||
);
|
||||
}
|
||||
|
||||
function commandCandidates(command, platform) {
|
||||
if (platform !== "win32") {
|
||||
return [command];
|
||||
@@ -232,6 +267,7 @@ const awsMacosPackageManagerScriptTargets = new Set([
|
||||
"scripts/restart-mac.sh",
|
||||
]);
|
||||
const minimumBlacksmithCrabboxVersion = [0, 22, 0];
|
||||
const minimumBrokeredDaytonaCrabboxVersion = [0, 40, 0];
|
||||
const shellControlCommandPrefixes = new Set([
|
||||
"if",
|
||||
"while",
|
||||
@@ -395,6 +431,21 @@ function checkedOutput(
|
||||
};
|
||||
}
|
||||
|
||||
function recoveryCommand(commandArgs) {
|
||||
return [binary, ...commandArgs].map(recoveryCommandArgument).join(" ");
|
||||
}
|
||||
|
||||
function recoveryCommandArgument(value) {
|
||||
const text = `${value}`;
|
||||
if (/^[A-Za-z0-9_./:@%+=,-]+$/u.test(text)) {
|
||||
return text;
|
||||
}
|
||||
if (process.platform === "win32") {
|
||||
return `"${text.replaceAll('"', '""')}"`;
|
||||
}
|
||||
return `'${text.replaceAll("'", "'\\''")}'`;
|
||||
}
|
||||
|
||||
// Probe Crabbox metadata (`--version` / `run --help`) with one generous retry.
|
||||
// A cold Crabbox can be SIGKILLed by the snappy default timeout or emit nothing
|
||||
// on the first call, then be instant and clean on the next. Retrying keeps the
|
||||
@@ -479,6 +530,27 @@ function gitOutput(commandArgs) {
|
||||
};
|
||||
}
|
||||
|
||||
let resolvedCrabboxConfigCache;
|
||||
|
||||
function resolvedCrabboxConfig() {
|
||||
if (resolvedCrabboxConfigCache !== undefined) {
|
||||
return resolvedCrabboxConfigCache;
|
||||
}
|
||||
const result = checkedOutput(binary, ["config", "show", "--json"]);
|
||||
if (result.status !== 0) {
|
||||
resolvedCrabboxConfigCache = null;
|
||||
return resolvedCrabboxConfigCache;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(result.stdout || result.text);
|
||||
resolvedCrabboxConfigCache =
|
||||
parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
|
||||
} catch {
|
||||
resolvedCrabboxConfigCache = null;
|
||||
}
|
||||
return resolvedCrabboxConfigCache;
|
||||
}
|
||||
|
||||
function envProvider() {
|
||||
const envProviderValue = process.env.CRABBOX_PROVIDER?.trim();
|
||||
if (envProviderValue) {
|
||||
@@ -488,6 +560,10 @@ function envProvider() {
|
||||
}
|
||||
|
||||
function configProvider() {
|
||||
const resolved = resolvedCrabboxConfig()?.provider;
|
||||
if (typeof resolved === "string" && resolved.trim()) {
|
||||
return resolved.trim();
|
||||
}
|
||||
try {
|
||||
const config = readFileSync(resolve(repoRoot, ".crabbox.yaml"), "utf8");
|
||||
const match = config.match(/^provider:\s*([^\s#]+)/m);
|
||||
@@ -497,8 +573,24 @@ function configProvider() {
|
||||
}
|
||||
}
|
||||
|
||||
function configuredProvider() {
|
||||
return envProvider() || configProvider();
|
||||
function effectiveTargetContext(commandArgs) {
|
||||
const config = resolvedCrabboxConfig();
|
||||
const configuredTarget = typeof config?.target === "string" ? config.target.trim() : "";
|
||||
const configuredWindowsMode =
|
||||
typeof config?.windowsMode === "string" ? config.windowsMode.trim() : "";
|
||||
return {
|
||||
target: (
|
||||
optionValue(commandArgs, "--target") ||
|
||||
process.env.CRABBOX_TARGET?.trim() ||
|
||||
process.env.CRABBOX_TARGET_OS?.trim() ||
|
||||
configuredTarget
|
||||
).toLowerCase(),
|
||||
windowsMode: (
|
||||
optionValue(commandArgs, "--windows-mode") ||
|
||||
process.env.CRABBOX_WINDOWS_MODE?.trim() ||
|
||||
configuredWindowsMode
|
||||
).toLowerCase(),
|
||||
};
|
||||
}
|
||||
|
||||
const runValueOptions = new Set([
|
||||
@@ -682,62 +774,305 @@ function commandProvider(commandArgsInput) {
|
||||
return "";
|
||||
}
|
||||
|
||||
function selectedProvider(commandArgs, advertisedProviders = []) {
|
||||
function selectedProvider(commandArgs, advertisedProviders = [], versionText = "") {
|
||||
const targetContext = effectiveTargetContext(commandArgs);
|
||||
if (workloadOption === null) {
|
||||
return {
|
||||
provider: "",
|
||||
source: "policy",
|
||||
workload: "",
|
||||
chain: [],
|
||||
error: "--workload requires a value",
|
||||
};
|
||||
}
|
||||
const workload = requestedWorkload(commandArgs);
|
||||
if (workload === null) {
|
||||
return {
|
||||
provider: "",
|
||||
source: "policy",
|
||||
workload: workloadOption ?? process.env.OPENCLAW_CRABBOX_WORKLOAD ?? "",
|
||||
chain: [],
|
||||
error: `unsupported Crabbox workload ${JSON.stringify(workloadOption ?? process.env.OPENCLAW_CRABBOX_WORKLOAD)}`,
|
||||
};
|
||||
}
|
||||
if (workload === "windows" && targetContext.target !== "windows") {
|
||||
return {
|
||||
provider: "",
|
||||
source: "policy",
|
||||
workload,
|
||||
chain: [],
|
||||
error: "Crabbox workload=windows requires target=windows",
|
||||
};
|
||||
}
|
||||
const configured = canonicalProviderName(configProvider());
|
||||
const chain = workload
|
||||
? crabboxProviderChain({
|
||||
workload,
|
||||
configuredProvider: configured,
|
||||
target: targetContext.target,
|
||||
advertisedProviders: advertisedProviders.map(canonicalProviderName),
|
||||
})
|
||||
: [];
|
||||
if (workload === "untrusted" && hasOption(commandArgs, "--id")) {
|
||||
return {
|
||||
provider: "",
|
||||
source: "policy",
|
||||
workload,
|
||||
chain,
|
||||
error:
|
||||
"Crabbox workload=untrusted requires a fresh lease; --id reuse is forbidden without persisted workload provenance",
|
||||
};
|
||||
}
|
||||
const explicitProvider = commandProvider(commandArgs);
|
||||
if (explicitProvider) {
|
||||
return explicitProvider;
|
||||
const canonicalExplicitProvider = canonicalProviderName(explicitProvider);
|
||||
if (workload && !chain.includes(canonicalExplicitProvider)) {
|
||||
return {
|
||||
provider: "",
|
||||
source: "explicit",
|
||||
workload,
|
||||
chain,
|
||||
error: `provider=${canonicalExplicitProvider} is not eligible for workload=${workload}; allowed=${chain.join(",") || "none"}`,
|
||||
};
|
||||
}
|
||||
return { provider: explicitProvider, source: "explicit", workload, chain };
|
||||
}
|
||||
if (shouldPreferAzureForWindows(commandArgs, advertisedProviders)) {
|
||||
return "azure";
|
||||
const environmentProvider = envProvider();
|
||||
if (environmentProvider) {
|
||||
const canonicalEnvironmentProvider = canonicalProviderName(environmentProvider);
|
||||
if (workload && !chain.includes(canonicalEnvironmentProvider)) {
|
||||
return {
|
||||
provider: "",
|
||||
source: "environment",
|
||||
workload,
|
||||
chain,
|
||||
error: `provider=${canonicalEnvironmentProvider} is not eligible for workload=${workload}; allowed=${chain.join(",") || "none"}`,
|
||||
};
|
||||
}
|
||||
return { provider: environmentProvider, source: "environment", workload, chain };
|
||||
}
|
||||
return configuredProvider();
|
||||
if (workload && hasOption(commandArgs, "--id")) {
|
||||
return {
|
||||
provider: "",
|
||||
source: "policy",
|
||||
workload,
|
||||
chain: [],
|
||||
error:
|
||||
"reusing a workload-routed lease with --id requires --provider (or CRABBOX_PROVIDER) from the originating route",
|
||||
};
|
||||
}
|
||||
if (!workload && shouldPreferAzureForWindows(commandArgs, advertisedProviders)) {
|
||||
return { provider: "azure", source: "windows-default", workload: "", chain: [] };
|
||||
}
|
||||
if (!workload) {
|
||||
return { provider: configured, source: "config", workload: "", chain: [] };
|
||||
}
|
||||
|
||||
const readiness = new Map();
|
||||
let selectedProviderName = "";
|
||||
for (const candidate of chain) {
|
||||
const status = crabboxProviderReadiness(candidate, versionText, targetContext);
|
||||
readiness.set(candidate, status);
|
||||
if (status.ready) {
|
||||
selectedProviderName = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!selectedProviderName) {
|
||||
return {
|
||||
provider: "",
|
||||
source: "policy",
|
||||
workload,
|
||||
chain,
|
||||
readiness,
|
||||
error: `no ready provider for workload=${workload}`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
provider: selectedProviderName,
|
||||
source: "policy",
|
||||
workload,
|
||||
chain,
|
||||
readiness,
|
||||
};
|
||||
}
|
||||
|
||||
function shouldRequireBrokeredAws(commandArgs, providerName) {
|
||||
if (process.env.OPENCLAW_CRABBOX_ALLOW_DIRECT_AWS === "1") {
|
||||
return false;
|
||||
function requestedWorkload(commandArgs) {
|
||||
if (!isWorkloadRoutedCommand(commandArgs)) {
|
||||
return "";
|
||||
}
|
||||
const raw = workloadOption ?? process.env.OPENCLAW_CRABBOX_WORKLOAD?.trim() ?? "";
|
||||
if (!raw) {
|
||||
return "";
|
||||
}
|
||||
return normalizeCrabboxWorkload(raw);
|
||||
}
|
||||
|
||||
let managedBrokerAuthConfiguredCache;
|
||||
|
||||
function crabboxProviderReadiness(providerName, versionText, targetContext) {
|
||||
const canonicalProvider = canonicalProviderName(providerName);
|
||||
if (canonicalProvider !== "aws") {
|
||||
if (
|
||||
canonicalProvider === "blacksmith-testbox" &&
|
||||
!satisfiesMinimumCrabboxVersion(versionText, minimumBlacksmithCrabboxVersion)
|
||||
) {
|
||||
return {
|
||||
ready: false,
|
||||
reason: `requires Crabbox >= ${formatVersionTuple(minimumBlacksmithCrabboxVersion)} for Blacksmith Testbox`,
|
||||
recovery: "update Crabbox, then retry",
|
||||
};
|
||||
}
|
||||
if (
|
||||
canonicalProvider === "daytona" &&
|
||||
!satisfiesMinimumCrabboxVersion(versionText, minimumBrokeredDaytonaCrabboxVersion)
|
||||
) {
|
||||
return {
|
||||
ready: false,
|
||||
reason: `requires Crabbox >= ${formatVersionTuple(minimumBrokeredDaytonaCrabboxVersion)} for brokered Daytona`,
|
||||
recovery: "update Crabbox, then retry",
|
||||
};
|
||||
}
|
||||
if (["aws", "azure", "daytona"].includes(canonicalProvider) && !managedBrokerAuthConfigured()) {
|
||||
return {
|
||||
ready: false,
|
||||
reason: "managed Crabbox broker auth unavailable",
|
||||
recovery: `run \`${recoveryCommand(["login", "--url", "https://crabbox.openclaw.ai"])}\`, then retry`,
|
||||
};
|
||||
}
|
||||
const doctorArgs = ["doctor", "--provider", canonicalProvider];
|
||||
if (targetContext.target) {
|
||||
doctorArgs.push("--target", targetContext.target);
|
||||
}
|
||||
if (targetContext.target === "windows" && targetContext.windowsMode) {
|
||||
doctorArgs.push("--windows-mode", targetContext.windowsMode);
|
||||
}
|
||||
doctorArgs.push("--json");
|
||||
const doctor = checkedOutput(binary, doctorArgs);
|
||||
if (doctor.status !== 0) {
|
||||
const diagnostic = compactDiagnosticText(doctor.text);
|
||||
return {
|
||||
ready: false,
|
||||
reason: `doctor exited ${doctor.status}${diagnostic ? `: ${diagnostic}` : ""}`,
|
||||
recovery: `run \`${recoveryCommand(doctorArgs)}\``,
|
||||
};
|
||||
}
|
||||
return { ready: true, reason: "doctor-ready" };
|
||||
}
|
||||
|
||||
function compactDiagnosticText(value, maxLength = 500) {
|
||||
const compact = `${value ?? ""}`.replace(/\s+/gu, " ").trim();
|
||||
if (compact.length <= maxLength) {
|
||||
return compact;
|
||||
}
|
||||
return `${compact.slice(0, Math.max(0, maxLength - 3))}...`;
|
||||
}
|
||||
|
||||
function formatProviderReadiness(readiness) {
|
||||
return [...readiness.entries()]
|
||||
.map(([candidate, status]) => `${candidate}:${status.ready ? "ready" : status.reason}`)
|
||||
.join(",");
|
||||
}
|
||||
|
||||
function providerRecoveryAdvice(readiness) {
|
||||
return [
|
||||
...new Set(
|
||||
[...readiness.values()]
|
||||
.map((status) => status.recovery)
|
||||
.filter((recovery) => typeof recovery === "string" && recovery.length > 0),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
function shouldRequireBrokeredCloud(commandArgs, providerName, explicitProviderRequested = false) {
|
||||
const canonicalProvider = canonicalProviderName(providerName);
|
||||
if (!["aws", "azure", "daytona"].includes(canonicalProvider)) {
|
||||
// Blacksmith Testbox is provider-owned and does not use the managed
|
||||
// coordinator auth required by brokered cloud capacity.
|
||||
return false;
|
||||
}
|
||||
if (commandArgs[0] === "run" || commandArgs[0] === "warmup") {
|
||||
// Route policy wins before explicit-provider and direct-debug exemptions.
|
||||
if (requestedWorkload(commandArgs)) {
|
||||
return true;
|
||||
}
|
||||
return commandArgs[0] === "actions" && commandArgs[1] === "hydrate";
|
||||
if (explicitProviderRequested && directCloudOverrideEnabled(providerName)) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
commandArgs[0] === "run" ||
|
||||
commandArgs[0] === "warmup" ||
|
||||
(commandArgs[0] === "actions" && commandArgs[1] === "hydrate")
|
||||
);
|
||||
}
|
||||
|
||||
function brokerAuthConfigured() {
|
||||
const config = checkedOutput(binary, ["config", "show", "--json"]);
|
||||
if (config.status !== 0) {
|
||||
return false;
|
||||
}
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(config.stdout || config.text);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (!parsed?.coordinator || parsed?.brokerAuth !== "configured") {
|
||||
return false;
|
||||
}
|
||||
return checkedOutput(binary, ["whoami"]).status === 0;
|
||||
function directCloudOverrideEnabled(providerName) {
|
||||
return (
|
||||
canonicalProviderName(providerName) !== "aws" &&
|
||||
process.env.OPENCLAW_CRABBOX_ALLOW_DIRECT_CLOUD === "1"
|
||||
);
|
||||
}
|
||||
|
||||
function enforceBrokeredAws(commandArgs, providerName) {
|
||||
if (!shouldRequireBrokeredAws(commandArgs, providerName) || brokerAuthConfigured()) {
|
||||
function managedBrokerAuthConfigured() {
|
||||
if (managedBrokerAuthConfiguredCache !== undefined) {
|
||||
return managedBrokerAuthConfiguredCache;
|
||||
}
|
||||
const parsed = resolvedCrabboxConfig();
|
||||
if (
|
||||
!parsed?.coordinator ||
|
||||
parsed?.brokerMode !== "managed" ||
|
||||
parsed?.brokerAuth !== "configured"
|
||||
) {
|
||||
managedBrokerAuthConfiguredCache = false;
|
||||
return managedBrokerAuthConfiguredCache;
|
||||
}
|
||||
managedBrokerAuthConfiguredCache = checkedOutput(binary, ["whoami"]).status === 0;
|
||||
return managedBrokerAuthConfiguredCache;
|
||||
}
|
||||
|
||||
function enforceBrokeredDaytonaVersion(
|
||||
commandArgs,
|
||||
providerName,
|
||||
versionText,
|
||||
explicitProviderRequested,
|
||||
) {
|
||||
if (
|
||||
canonicalProviderName(providerName) !== "daytona" ||
|
||||
!shouldRequireBrokeredCloud(commandArgs, providerName, explicitProviderRequested) ||
|
||||
satisfiesMinimumCrabboxVersion(versionText, minimumBrokeredDaytonaCrabboxVersion)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
console.error(
|
||||
[
|
||||
"[crabbox] provider=aws requires a configured Crabbox broker for OpenClaw proof.",
|
||||
"[crabbox] run `crabbox login --url https://crabbox.openclaw.ai --provider aws`, then retry.",
|
||||
"[crabbox] for intentional direct AWS provider debugging, set OPENCLAW_CRABBOX_ALLOW_DIRECT_AWS=1.",
|
||||
`[crabbox] provider=daytona requires Crabbox >= ${formatVersionTuple(minimumBrokeredDaytonaCrabboxVersion)} for brokered execution.`,
|
||||
`[crabbox] selected binary reported version=${versionText || "unknown"}.`,
|
||||
"[crabbox] update Crabbox before brokered Daytona execution.",
|
||||
"[crabbox] direct Daytona debugging requires an original `--provider daytona`, no `--workload`, and OPENCLAW_CRABBOX_ALLOW_DIRECT_CLOUD=1.",
|
||||
].join("\n"),
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
function enforceBrokeredCloud(commandArgs, providerName, explicitProviderRequested) {
|
||||
if (
|
||||
!shouldRequireBrokeredCloud(commandArgs, providerName, explicitProviderRequested) ||
|
||||
managedBrokerAuthConfigured()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const canonicalProvider = canonicalProviderName(providerName);
|
||||
const instructions = [
|
||||
`[crabbox] provider=${canonicalProvider} requires a configured managed Crabbox broker for OpenClaw proof.`,
|
||||
`[crabbox] run \`${recoveryCommand(["login", "--url", "https://crabbox.openclaw.ai"])}\`, then retry.`,
|
||||
];
|
||||
if (canonicalProvider !== "aws") {
|
||||
instructions.push(
|
||||
`[crabbox] direct ${canonicalProvider} debugging requires an original \`--provider ${canonicalProvider}\`, no \`--workload\`, and OPENCLAW_CRABBOX_ALLOW_DIRECT_CLOUD=1.`,
|
||||
);
|
||||
}
|
||||
console.error(instructions.join("\n"));
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
function optionValue(commandArgsInput, name) {
|
||||
let commandArgs = commandArgsInput;
|
||||
commandArgs = crabboxOptionArgs(commandArgs);
|
||||
@@ -800,6 +1135,21 @@ function ensureAzureWindowsProvider(commandArgs, providerName, advertisedProvide
|
||||
return normalizedArgs;
|
||||
}
|
||||
|
||||
function ensurePolicyProvider(commandArgs, selection) {
|
||||
if (
|
||||
selection.source !== "policy" ||
|
||||
!selection.provider ||
|
||||
commandProvider(commandArgs) ||
|
||||
envProvider()
|
||||
) {
|
||||
return commandArgs;
|
||||
}
|
||||
const normalizedArgs = [...commandArgs];
|
||||
const optionEnd = commandOptionEnd(normalizedArgs);
|
||||
normalizedArgs.splice(optionEnd, 0, "--provider", selection.provider);
|
||||
return normalizedArgs;
|
||||
}
|
||||
|
||||
function ensureAwsMacOnDemandMarket(commandArgs, providerName) {
|
||||
if (
|
||||
!["run", "warmup"].includes(commandArgs[0]) ||
|
||||
@@ -3483,21 +3833,46 @@ const version = probeCrabboxMetadata(binary, ["--version"]);
|
||||
const help = probeCrabboxMetadata(binary, ["run", "--help"]);
|
||||
const providers = parseProvidersFromHelp(help.text);
|
||||
const displayBinary = binary === "crabbox" ? "crabbox" : relative(repoRoot, binary);
|
||||
const provider = selectedProvider(args, providers);
|
||||
|
||||
if (version.status !== 0 || help.status !== 0) {
|
||||
console.error(
|
||||
`[crabbox] bin=${displayBinary} version=${version.text || "unknown"} providers=${providers.join(",") || "unknown"}`,
|
||||
);
|
||||
console.error("[crabbox] selected binary failed basic --version/--help sanity checks");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const providerSelection = selectedProvider(args, providers, version.text);
|
||||
if (providerSelection.error) {
|
||||
console.error(`[crabbox] ${providerSelection.error}`);
|
||||
if (providerSelection.readiness) {
|
||||
console.error(
|
||||
`[crabbox] provider readiness ${formatProviderReadiness(providerSelection.readiness)}`,
|
||||
);
|
||||
for (const recovery of providerRecoveryAdvice(providerSelection.readiness)) {
|
||||
console.error(`[crabbox] recovery: ${recovery}`);
|
||||
}
|
||||
}
|
||||
process.exit(2);
|
||||
}
|
||||
const provider = providerSelection.provider;
|
||||
const canonicalProvider = canonicalProviderName(provider);
|
||||
const commandProviderValue = commandProvider(args);
|
||||
let normalizedArgs = ensureAwsMacOnDemandMarket(
|
||||
ensureNativeWindowsHydrateJob(ensureAzureWindowsProvider(args, provider, providers)),
|
||||
ensurePolicyProvider(
|
||||
ensureNativeWindowsHydrateJob(ensureAzureWindowsProvider(args, provider, providers)),
|
||||
providerSelection,
|
||||
),
|
||||
provider,
|
||||
);
|
||||
|
||||
console.error(
|
||||
`[crabbox] bin=${displayBinary} version=${version.text || "unknown"} provider=${provider || "unknown"} providers=${providers.join(",") || "unknown"}`,
|
||||
);
|
||||
|
||||
if (version.status !== 0 || help.status !== 0) {
|
||||
console.error("[crabbox] selected binary failed basic --version/--help sanity checks");
|
||||
process.exit(2);
|
||||
if (providerSelection.source === "policy") {
|
||||
console.error(
|
||||
`[crabbox] route workload=${providerSelection.workload} selected=${provider} chain=${providerSelection.chain.join(",")} readiness=${formatProviderReadiness(providerSelection.readiness)}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (provider && !isProviderAdvertised(provider, providers)) {
|
||||
@@ -3536,7 +3911,9 @@ if (canonicalProvider === "blacksmith-testbox") {
|
||||
}
|
||||
}
|
||||
|
||||
enforceBrokeredAws(normalizedArgs, provider);
|
||||
const explicitProviderRequested = Boolean(commandProviderValue);
|
||||
enforceBrokeredDaytonaVersion(normalizedArgs, provider, version.text, explicitProviderRequested);
|
||||
enforceBrokeredCloud(normalizedArgs, provider, explicitProviderRequested);
|
||||
|
||||
if (canonicalProvider === "blacksmith-testbox") {
|
||||
const envProviderLocal = process.env.CRABBOX_PROVIDER?.trim();
|
||||
|
||||
@@ -0,0 +1,397 @@
|
||||
#!/usr/bin/env node
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
||||
import { basename, dirname, extname, resolve } from "node:path";
|
||||
|
||||
const DEFAULT_OUTPUT = ".artifacts/dated-todo-candidates.json";
|
||||
const MAX_FILE_BYTES = 2 * 1024 * 1024;
|
||||
const MAX_CANDIDATES = 5_000;
|
||||
const TEXT_EXTENSIONS = new Set([
|
||||
".bash",
|
||||
".cjs",
|
||||
".css",
|
||||
".cts",
|
||||
".go",
|
||||
".html",
|
||||
".java",
|
||||
".js",
|
||||
".json",
|
||||
".jsx",
|
||||
".kt",
|
||||
".kts",
|
||||
".md",
|
||||
".mdx",
|
||||
".mjs",
|
||||
".mts",
|
||||
".php",
|
||||
".py",
|
||||
".rb",
|
||||
".rs",
|
||||
".scss",
|
||||
".sh",
|
||||
".swift",
|
||||
".toml",
|
||||
".ts",
|
||||
".tsx",
|
||||
".xml",
|
||||
".yaml",
|
||||
".yml",
|
||||
".zsh",
|
||||
]);
|
||||
const EXCLUDED_SEGMENTS = new Set([
|
||||
".artifacts",
|
||||
".generated",
|
||||
".git",
|
||||
".i18n",
|
||||
".next",
|
||||
"__fixtures__",
|
||||
"build",
|
||||
"coverage",
|
||||
"dist",
|
||||
"dist-runtime",
|
||||
"fixtures",
|
||||
"generated",
|
||||
"i18n",
|
||||
"locales",
|
||||
"node_modules",
|
||||
"test-fixtures",
|
||||
"translations",
|
||||
"vendor",
|
||||
]);
|
||||
const EXCLUDED_BASENAMES = new Set([
|
||||
"bun.lock",
|
||||
"bun.lockb",
|
||||
"CHANGELOG.md",
|
||||
"package-lock.json",
|
||||
"pnpm-lock.yaml",
|
||||
"yarn.lock",
|
||||
]);
|
||||
const TODO_PATTERN =
|
||||
/\b(?:TODO|FIXME|HACK|removeAfter|remove\s+after|delete\s+after|until|deadline|expires?|expiry|expiration|deprecated|deprecation|window|re-?enable|temporary)\b/iu;
|
||||
const DATE_PATTERN =
|
||||
/(?:\b20\d{2}-\d{2}-\d{2}(?=\b|T)|\b(?:Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|Jun(?:e)?|Jul(?:y)?|Aug(?:ust)?|Sep(?:t(?:ember)?)?|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?)\s+(?:\d{1,2}(?:st|nd|rd|th)?(?:,\s*|\s+))?20\d{2}\b)/iu;
|
||||
const ISO_DATE_PREFILTER = String.raw`20[0-9]{2}-[0-9]{2}-[0-9]{2}`;
|
||||
const MONTH_DATE_PREFILTER = String.raw`(Jan(uary)?|Feb(ruary)?|Mar(ch)?|Apr(il)?|May|Jun(e)?|Jul(y)?|Aug(ust)?|Sep(t(ember)?)?|Oct(ober)?|Nov(ember)?|Dec(ember)?)\s+([0-9]{1,2}(st|nd|rd|th)?(,\s*|\s+))?20[0-9]{2}`;
|
||||
const GIT_MONTH_DATE_PREFILTER = MONTH_DATE_PREFILTER.replaceAll(String.raw`\s`, "[[:space:]]");
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = {
|
||||
root: process.cwd(),
|
||||
output: DEFAULT_OUTPUT,
|
||||
compatReport: undefined,
|
||||
};
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg === "--help" || arg === "-h") {
|
||||
process.stdout.write(
|
||||
"Usage: node scripts/dated-todo-scan.mjs [--root <dir>] [--output <path>] [--compat-report <path>]\n",
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
const value = argv[index + 1];
|
||||
if (!value || value.startsWith("--")) {
|
||||
throw new Error(`Missing value for ${arg}`);
|
||||
}
|
||||
if (arg === "--root") {
|
||||
options.root = value;
|
||||
} else if (arg === "--output") {
|
||||
options.output = value;
|
||||
} else if (arg === "--compat-report") {
|
||||
options.compatReport = value;
|
||||
} else {
|
||||
throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
options.root = resolve(options.root);
|
||||
options.output = resolve(options.root, options.output);
|
||||
if (options.compatReport) {
|
||||
options.compatReport = resolve(options.root, options.compatReport);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function run(command, args, cwd, maxBuffer = 32 * 1024 * 1024) {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd,
|
||||
encoding: "utf8",
|
||||
maxBuffer,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
if (result.error) {
|
||||
throw new Error(`Failed to run ${command}: ${result.error.message}`);
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
throw new Error(
|
||||
`${command} ${args.join(" ")} failed (${result.status ?? "unknown"}): ${result.stderr.trim()}`,
|
||||
);
|
||||
}
|
||||
return result.stdout;
|
||||
}
|
||||
|
||||
function runGitGrep(root, paths) {
|
||||
const result = spawnSync(
|
||||
"git",
|
||||
[
|
||||
"grep",
|
||||
"-l",
|
||||
"-I",
|
||||
"-i",
|
||||
"-E",
|
||||
"-e",
|
||||
ISO_DATE_PREFILTER,
|
||||
"-e",
|
||||
GIT_MONTH_DATE_PREFILTER,
|
||||
"--",
|
||||
...paths,
|
||||
],
|
||||
{
|
||||
cwd: root,
|
||||
encoding: "utf8",
|
||||
maxBuffer: 32 * 1024 * 1024,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
},
|
||||
);
|
||||
if (result.error) {
|
||||
throw new Error(`Failed to run git grep: ${result.error.message}`);
|
||||
}
|
||||
if (result.status !== 0 && result.status !== 1) {
|
||||
throw new Error(`git grep failed (${result.status ?? "unknown"}): ${result.stderr.trim()}`);
|
||||
}
|
||||
return result.stdout;
|
||||
}
|
||||
|
||||
function runDatePrefilter(root, paths) {
|
||||
const result = spawnSync(
|
||||
"rg",
|
||||
[
|
||||
"-l",
|
||||
"-i",
|
||||
"--hidden",
|
||||
"--no-ignore",
|
||||
"--no-messages",
|
||||
"-e",
|
||||
ISO_DATE_PREFILTER,
|
||||
"-e",
|
||||
MONTH_DATE_PREFILTER,
|
||||
"--glob",
|
||||
"!.git/**",
|
||||
"--glob",
|
||||
"!.i18n/**",
|
||||
"--glob",
|
||||
"!node_modules/**",
|
||||
"--glob",
|
||||
"!dist/**",
|
||||
"--glob",
|
||||
"!dist-runtime/**",
|
||||
...[...EXCLUDED_SEGMENTS].flatMap((segment) => ["--glob", `!**/${segment}/**`]),
|
||||
...paths,
|
||||
],
|
||||
{
|
||||
cwd: root,
|
||||
encoding: "utf8",
|
||||
maxBuffer: 32 * 1024 * 1024,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
},
|
||||
);
|
||||
if (result.error?.code === "ENOENT") {
|
||||
return runGitGrep(root, paths);
|
||||
}
|
||||
if (result.error) {
|
||||
throw new Error(`Failed to run rg: ${result.error.message}`);
|
||||
}
|
||||
if (result.status !== 0 && result.status !== 1) {
|
||||
throw new Error(`rg failed (${result.status ?? "unknown"}): ${result.stderr.trim()}`);
|
||||
}
|
||||
return result.stdout;
|
||||
}
|
||||
|
||||
function isScannablePath(file) {
|
||||
const normalized = file.replaceAll("\\", "/");
|
||||
const parts = normalized.split("/");
|
||||
const name = basename(normalized);
|
||||
return (
|
||||
TEXT_EXTENSIONS.has(extname(name).toLowerCase()) &&
|
||||
!EXCLUDED_BASENAMES.has(name) &&
|
||||
!parts.some((part) => EXCLUDED_SEGMENTS.has(part)) &&
|
||||
!/(?:^|[.-])generated(?:[.-]|$)/iu.test(name) &&
|
||||
!/(?:^|[.-])api-baseline(?:[.-]|$)/iu.test(name)
|
||||
);
|
||||
}
|
||||
|
||||
function compactText(lines) {
|
||||
return [...new Set(lines.map((line) => line.trim()).filter(Boolean))]
|
||||
.join(" | ")
|
||||
.replaceAll(/\s+/gu, " ")
|
||||
.slice(0, 500);
|
||||
}
|
||||
|
||||
function loadFiles(root, files) {
|
||||
return files
|
||||
.filter(Boolean)
|
||||
.filter(isScannablePath)
|
||||
.toSorted()
|
||||
.flatMap((file) => {
|
||||
const absolutePath = resolve(root, file);
|
||||
if (statSync(absolutePath).size > MAX_FILE_BYTES) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
file: file.replaceAll("\\", "/"),
|
||||
lines: readFileSync(absolutePath, "utf8").split(/\r?\n/u),
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
function loadPrefilteredFiles(root) {
|
||||
const tracked = new Set(
|
||||
run("git", ["ls-files", "--cached", "-z"], root)
|
||||
.split("\0")
|
||||
.filter((file) => file && isScannablePath(file)),
|
||||
);
|
||||
const files = runDatePrefilter(root, ["."])
|
||||
.split("\n")
|
||||
.map((file) => file.replace(/^\.\//u, ""))
|
||||
.filter((file) => tracked.has(file));
|
||||
return loadFiles(root, files);
|
||||
}
|
||||
|
||||
function loadCompatFiles(root) {
|
||||
const output = run("git", ["ls-files", "--cached", "-z", "--", "src/plugins/compat"], root);
|
||||
return loadFiles(root, output.split("\0"));
|
||||
}
|
||||
|
||||
function collectScanCandidates(files) {
|
||||
const candidates = [];
|
||||
for (const { file, lines } of files) {
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
if (!TODO_PATTERN.test(lines[index] ?? "")) {
|
||||
continue;
|
||||
}
|
||||
const nearbyDates = [];
|
||||
for (
|
||||
let nearby = Math.max(0, index - 1);
|
||||
nearby <= Math.min(lines.length - 1, index + 1);
|
||||
nearby += 1
|
||||
) {
|
||||
if (DATE_PATTERN.test(lines[nearby] ?? "")) {
|
||||
nearbyDates.push(lines[nearby] ?? "");
|
||||
}
|
||||
}
|
||||
if (nearbyDates.length === 0) {
|
||||
continue;
|
||||
}
|
||||
candidates.push({
|
||||
file,
|
||||
line: index + 1,
|
||||
text: compactText([lines[index] ?? "", ...nearbyDates]),
|
||||
source: "scan",
|
||||
});
|
||||
}
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function readCompatReport(options) {
|
||||
if (options.compatReport) {
|
||||
return JSON.parse(readFileSync(options.compatReport, "utf8"));
|
||||
}
|
||||
const reportScript = resolve(options.root, "scripts/plugin-boundary-report.ts");
|
||||
const output = run(
|
||||
process.execPath,
|
||||
["--import", "tsx", reportScript, "--json"],
|
||||
options.root,
|
||||
16 * 1024 * 1024,
|
||||
);
|
||||
return JSON.parse(output);
|
||||
}
|
||||
|
||||
function findCompatLocation(code, files) {
|
||||
const escapedCode = code.replaceAll(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
||||
const codeField = new RegExp(`\\bcode\\s*:\\s*["']${escapedCode}["']`, "u");
|
||||
for (const { file, lines } of files) {
|
||||
const index = lines.findIndex((line) => codeField.test(line));
|
||||
if (index >= 0) {
|
||||
return { file, line: index + 1 };
|
||||
}
|
||||
}
|
||||
return { file: "src/plugins/compat/registry.ts", line: 1 };
|
||||
}
|
||||
|
||||
function collectCompatCandidates(report, files) {
|
||||
const records = report?.compat?.records;
|
||||
if (!Array.isArray(records)) {
|
||||
throw new Error("Plugin boundary report is missing compat.records");
|
||||
}
|
||||
const locationFiles = [...files].toSorted((left, right) => {
|
||||
const leftCompat = left.file.startsWith("src/plugins/compat/") ? 0 : 1;
|
||||
const rightCompat = right.file.startsWith("src/plugins/compat/") ? 0 : 1;
|
||||
return leftCompat - rightCompat || left.file.localeCompare(right.file);
|
||||
});
|
||||
return records
|
||||
.filter(
|
||||
(record) =>
|
||||
record?.status === "deprecated" &&
|
||||
typeof record.code === "string" &&
|
||||
typeof record.removeAfter === "string",
|
||||
)
|
||||
.map((record) => {
|
||||
const location = findCompatLocation(record.code, locationFiles);
|
||||
return {
|
||||
file: location.file,
|
||||
line: location.line,
|
||||
text: compactText([
|
||||
`${record.code}: removeAfter ${record.removeAfter}`,
|
||||
typeof record.replacement === "string" ? `replacement ${record.replacement}` : "",
|
||||
]),
|
||||
source: "compat-registry",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function sortAndDeduplicate(candidates) {
|
||||
const unique = new Map();
|
||||
for (const candidate of candidates) {
|
||||
unique.set(
|
||||
`${candidate.source}\0${candidate.file}\0${candidate.line}\0${candidate.text}`,
|
||||
candidate,
|
||||
);
|
||||
}
|
||||
return [...unique.values()].toSorted(
|
||||
(left, right) =>
|
||||
left.file.localeCompare(right.file) ||
|
||||
left.line - right.line ||
|
||||
left.source.localeCompare(right.source) ||
|
||||
left.text.localeCompare(right.text),
|
||||
);
|
||||
}
|
||||
|
||||
function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
const scanCandidates = collectScanCandidates(loadPrefilteredFiles(options.root));
|
||||
const compatCandidates = collectCompatCandidates(
|
||||
readCompatReport(options),
|
||||
loadCompatFiles(options.root),
|
||||
);
|
||||
const candidates = sortAndDeduplicate([...scanCandidates, ...compatCandidates]);
|
||||
if (candidates.length > MAX_CANDIDATES) {
|
||||
throw new Error(
|
||||
`Dated TODO prefilter produced ${candidates.length} candidates, above the ${MAX_CANDIDATES} safety cap`,
|
||||
);
|
||||
}
|
||||
mkdirSync(dirname(options.output), { recursive: true });
|
||||
writeFileSync(options.output, `${JSON.stringify(candidates, null, 2)}\n`);
|
||||
process.stdout.write(
|
||||
`dated-todo-scan: wrote ${candidates.length} candidates (${scanCandidates.length} scan, ${compatCandidates.length} compat-registry) to ${options.output}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
process.stderr.write(`dated-todo-scan: ${message}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
@@ -11,11 +11,8 @@ import {
|
||||
} from "./pre-commit/pnpm-audit-prod.mjs";
|
||||
|
||||
const DEPENDENCY_FILE_PATTERNS = [
|
||||
/^\.github\/release\/clawhub-cli\/package-lock\.json$/u,
|
||||
/^package\.json$/u,
|
||||
/^package-lock\.json$/u,
|
||||
/\/package-lock\.json$/u,
|
||||
/^npm-shrinkwrap\.json$/u,
|
||||
/\/npm-shrinkwrap\.json$/u,
|
||||
/^pnpm-lock\.yaml$/u,
|
||||
/^pnpm-workspace\.yaml$/u,
|
||||
/^patches\//u,
|
||||
@@ -23,13 +20,10 @@ const DEPENDENCY_FILE_PATTERNS = [
|
||||
];
|
||||
|
||||
const DEPENDENCY_DIFF_PATHS = [
|
||||
".github/release/clawhub-cli/package-lock.json",
|
||||
"package.json",
|
||||
"package-lock.json",
|
||||
"extensions/*/package-lock.json",
|
||||
"npm-shrinkwrap.json",
|
||||
"pnpm-lock.yaml",
|
||||
"pnpm-workspace.yaml",
|
||||
"extensions/*/npm-shrinkwrap.json",
|
||||
"*package.json",
|
||||
"patches",
|
||||
];
|
||||
@@ -128,8 +122,8 @@ function renderMarkdownReport(report) {
|
||||
"",
|
||||
"It reports two related but different things:",
|
||||
"",
|
||||
"- Dependency file changes: package manifests, npm shrinkwrap/package-lock, pnpm workspace config, lockfile, and patches.",
|
||||
"- Resolved package changes: package versions added, removed, or changed in pnpm-lock.yaml; inspect shrinkwrap/package-lock diffs directly.",
|
||||
"- Dependency file changes: package manifests, pnpm workspace config, pnpm lockfile, the trusted ClawHub CLI package lock, and patches.",
|
||||
"- Resolved package changes: package versions added, removed, or changed in pnpm-lock.yaml.",
|
||||
"",
|
||||
"## Summary",
|
||||
"",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Gateway Smoke script supports OpenClaw repository automation.
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { isRecord } from "@openclaw/normalization-core/record-coerce";
|
||||
import {
|
||||
MIN_CLIENT_PROTOCOL_VERSION,
|
||||
PROTOCOL_VERSION,
|
||||
@@ -88,10 +89,6 @@ function parseGatewaySmokeCli(
|
||||
};
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function hasHealthSummaryPayload(response: unknown): boolean {
|
||||
if (!isRecord(response) || !isRecord(response.payload)) {
|
||||
return false;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Ios Node E2E script supports OpenClaw repository automation.
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { isRecord } from "@openclaw/normalization-core/record-coerce";
|
||||
import {
|
||||
MIN_CLIENT_PROTOCOL_VERSION,
|
||||
PROTOCOL_VERSION,
|
||||
@@ -146,10 +147,6 @@ function parseWaitSeconds(raw: string | undefined): number {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
}
|
||||
|
||||
function payloadShapeError(command: string, payload: unknown): string | null {
|
||||
if (payload == null) {
|
||||
return `${command} returned no payload`;
|
||||
|
||||
@@ -15,6 +15,12 @@ const OPENAI_REALTIME_MODEL =
|
||||
const OPENAI_REALTIME_VOICE = process.env.OPENCLAW_REALTIME_OPENAI_VOICE?.trim() || "alloy";
|
||||
const DEFAULT_OPENAI_HTTP_TIMEOUT_MS = 30_000;
|
||||
const OPENAI_HTTP_RESPONSE_MAX_BYTES = 256 * 1024;
|
||||
const DEFAULT_OPENAI_AUDIO_CYCLES = 1;
|
||||
const MAX_OPENAI_AUDIO_CYCLES = 10;
|
||||
const OPENAI_AUDIO_CHUNK_BYTES = 960;
|
||||
const OPENAI_AUDIO_CHUNK_DELAY_MS = 5;
|
||||
const OPENAI_AUDIO_ROUNDTRIP_TIMEOUT_MS = 60_000;
|
||||
const OPENAI_AUDIO_TRAILING_SILENCE_MS = 750;
|
||||
const GOOGLE_REALTIME_MODEL =
|
||||
process.env.OPENCLAW_REALTIME_GOOGLE_MODEL?.trim() || "gemini-3.1-flash-live-preview";
|
||||
const GOOGLE_REALTIME_VOICE = process.env.OPENCLAW_REALTIME_GOOGLE_VOICE?.trim() || "Kore";
|
||||
@@ -23,11 +29,13 @@ const GOOGLE_LIVE_WS_URL =
|
||||
|
||||
type RealtimeSmokeCliOptions = {
|
||||
help: boolean;
|
||||
openAIAudioCycles: number;
|
||||
openAIOnly: boolean;
|
||||
};
|
||||
|
||||
// Keep live stacks behind their owning smoke paths so help and safety helpers stay lightweight.
|
||||
type Browser = import("playwright").Browser;
|
||||
type RealtimeVoiceBridge = import("../../src/talk/provider-types.ts").RealtimeVoiceBridge;
|
||||
type ViteDevServer = Awaited<ReturnType<(typeof import("vite"))["createServer"]>>;
|
||||
|
||||
type SmokeResult = {
|
||||
@@ -66,8 +74,9 @@ function usage(): string {
|
||||
"Usage: node --import tsx scripts/dev/realtime-talk-live-smoke.ts [options]",
|
||||
"",
|
||||
"Options:",
|
||||
" --openai-only Run only the OpenAI backend and browser legs",
|
||||
" -h, --help Show this help",
|
||||
" --openai-only Run only the OpenAI legs",
|
||||
" --openai-audio-cycles N Run 1-10 backend audio roundtrip cycles (default: 1)",
|
||||
" -h, --help Show this help",
|
||||
"",
|
||||
"Environment:",
|
||||
" OPENAI_API_KEY",
|
||||
@@ -76,14 +85,36 @@ function usage(): string {
|
||||
}
|
||||
|
||||
function parseRealtimeSmokeArgs(argv = process.argv.slice(2)): RealtimeSmokeCliOptions {
|
||||
for (const arg of argv) {
|
||||
let openAIAudioCycles = DEFAULT_OPENAI_AUDIO_CYCLES;
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg === "--help" || arg === "-h" || arg === "--openai-only") {
|
||||
continue;
|
||||
}
|
||||
if (arg === "--openai-audio-cycles") {
|
||||
const rawCycles = argv[index + 1];
|
||||
if (!rawCycles) {
|
||||
throw new CliArgumentError("--openai-audio-cycles requires a value");
|
||||
}
|
||||
openAIAudioCycles = parseStrictIntegerOption({
|
||||
fallback: DEFAULT_OPENAI_AUDIO_CYCLES,
|
||||
label: "--openai-audio-cycles",
|
||||
min: 1,
|
||||
raw: rawCycles,
|
||||
});
|
||||
if (openAIAudioCycles > MAX_OPENAI_AUDIO_CYCLES) {
|
||||
throw new CliArgumentError(
|
||||
`--openai-audio-cycles must be <= ${MAX_OPENAI_AUDIO_CYCLES}; got ${openAIAudioCycles}`,
|
||||
);
|
||||
}
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
throw new CliArgumentError(`Unknown argument: ${arg}`);
|
||||
}
|
||||
return {
|
||||
help: argv.includes("--help") || argv.includes("-h"),
|
||||
openAIAudioCycles,
|
||||
openAIOnly: argv.includes("--openai-only"),
|
||||
};
|
||||
}
|
||||
@@ -159,6 +190,57 @@ function compareStrings(left: string | undefined, right: string | undefined): nu
|
||||
return (left ?? "").localeCompare(right ?? "");
|
||||
}
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
function appendBounded<T>(items: T[], item: T, maxItems: number): void {
|
||||
if (items.length < maxItems) {
|
||||
items.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeTranscript(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replaceAll(/[^a-z0-9]+/gu, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function transcriptIncludesMarker(transcripts: string[], marker: string): boolean {
|
||||
return normalizeTranscript(transcripts.join(" ")).includes(normalizeTranscript(marker));
|
||||
}
|
||||
|
||||
function resolveGatewayRelayModulePath(repoRoot = process.cwd()): string {
|
||||
return `/@fs/${repoRoot.replaceAll("\\", "/")}/ui/src/pages/chat/realtime-talk-gateway-relay.ts`;
|
||||
}
|
||||
|
||||
async function sendPcmAudioInChunks(
|
||||
bridge: RealtimeVoiceBridge,
|
||||
audio: Buffer,
|
||||
options: { chunkBytes?: number; delayMs?: number } = {},
|
||||
): Promise<number> {
|
||||
const chunkBytes = options.chunkBytes ?? OPENAI_AUDIO_CHUNK_BYTES;
|
||||
const delayMs = options.delayMs ?? OPENAI_AUDIO_CHUNK_DELAY_MS;
|
||||
if (!Number.isSafeInteger(chunkBytes) || chunkBytes < 1) {
|
||||
throw new Error(`PCM audio chunk size must be a positive integer; got ${chunkBytes}`);
|
||||
}
|
||||
if (!Number.isFinite(delayMs) || delayMs < 0) {
|
||||
throw new Error(`PCM audio chunk delay must be a non-negative number; got ${delayMs}`);
|
||||
}
|
||||
let chunks = 0;
|
||||
for (let offset = 0; offset < audio.byteLength; offset += chunkBytes) {
|
||||
bridge.sendAudio(audio.subarray(offset, Math.min(offset + chunkBytes, audio.byteLength)));
|
||||
chunks += 1;
|
||||
if (delayMs > 0) {
|
||||
await delay(delayMs);
|
||||
}
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
async function readOpenAIRealtimeBrowserResponseText(
|
||||
response: Response,
|
||||
label: string,
|
||||
@@ -313,6 +395,180 @@ async function smokeOpenAIBackendBridge(apiKey: string): Promise<SmokeResult> {
|
||||
}
|
||||
}
|
||||
|
||||
async function smokeOpenAIAudioRoundtrip(apiKey: string, cycleCount: number): Promise<SmokeResult> {
|
||||
const cycles: Array<Record<string, unknown>> = [];
|
||||
try {
|
||||
const [
|
||||
{ buildOpenAIRealtimeVoiceProvider },
|
||||
{ buildOpenAISpeechProvider },
|
||||
{ REALTIME_VOICE_AUDIO_FORMAT_PCM16_24KHZ },
|
||||
] = await Promise.all([
|
||||
import("../../extensions/openai/realtime-voice-provider.ts"),
|
||||
import("../../extensions/openai/speech-provider.ts"),
|
||||
import("../../src/talk/provider-types.ts"),
|
||||
]);
|
||||
const speechProvider = buildOpenAISpeechProvider();
|
||||
const synthesized = await speechProvider.synthesizeTelephony?.({
|
||||
text: "Please reply with the single word glacier.",
|
||||
cfg: { plugins: { enabled: true } } as never,
|
||||
providerConfig: {
|
||||
apiKey,
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
model: "gpt-4o-mini-tts",
|
||||
voice: "alloy",
|
||||
},
|
||||
timeoutMs: 45_000,
|
||||
});
|
||||
if (!synthesized) {
|
||||
throw new Error("OpenAI speech provider did not return telephony audio");
|
||||
}
|
||||
if (synthesized.outputFormat !== "pcm" || synthesized.sampleRate !== 24_000) {
|
||||
throw new Error(
|
||||
`OpenAI speech provider returned ${synthesized.outputFormat} at ${synthesized.sampleRate} Hz`,
|
||||
);
|
||||
}
|
||||
const trailingSilenceBytes = Math.ceil(
|
||||
(synthesized.sampleRate * 2 * OPENAI_AUDIO_TRAILING_SILENCE_MS) / 1_000,
|
||||
);
|
||||
const inputAudio = Buffer.concat([synthesized.audioBuffer, Buffer.alloc(trailingSilenceBytes)]);
|
||||
|
||||
for (let cycle = 1; cycle <= cycleCount; cycle += 1) {
|
||||
const provider = buildOpenAIRealtimeVoiceProvider();
|
||||
const events: string[] = [];
|
||||
const finalUserTranscripts: string[] = [];
|
||||
const finalAssistantTranscripts: string[] = [];
|
||||
let outputAudioBytes = 0;
|
||||
let lateAudioBytes = 0;
|
||||
let responseDone = false;
|
||||
let closed = false;
|
||||
let resolveRoundtrip: ((error?: Error) => void) | undefined;
|
||||
const roundtrip = new Promise<Error | undefined>((resolve) => {
|
||||
resolveRoundtrip = resolve;
|
||||
});
|
||||
const maybeResolveRoundtrip = () => {
|
||||
if (
|
||||
responseDone &&
|
||||
outputAudioBytes > 512 &&
|
||||
transcriptIncludesMarker(finalUserTranscripts, "glacier") &&
|
||||
transcriptIncludesMarker(finalAssistantTranscripts, "glacier")
|
||||
) {
|
||||
resolveRoundtrip?.();
|
||||
}
|
||||
};
|
||||
const bridgeRef: { current?: RealtimeVoiceBridge } = {};
|
||||
const bridge = provider.createBridge({
|
||||
providerConfig: {
|
||||
apiKey,
|
||||
model: OPENAI_REALTIME_MODEL,
|
||||
voice: OPENAI_REALTIME_VOICE,
|
||||
vadThreshold: 0.1,
|
||||
silenceDurationMs: 500,
|
||||
prefixPaddingMs: 300,
|
||||
},
|
||||
audioFormat: REALTIME_VOICE_AUDIO_FORMAT_PCM16_24KHZ,
|
||||
instructions:
|
||||
"Follow the speaker's request exactly. Reply briefly and do not add commentary.",
|
||||
onAudio: (audio) => {
|
||||
if (closed) {
|
||||
lateAudioBytes += audio.byteLength;
|
||||
return;
|
||||
}
|
||||
outputAudioBytes += audio.byteLength;
|
||||
maybeResolveRoundtrip();
|
||||
},
|
||||
onClearAudio: () => {},
|
||||
onMark: (markName) => bridgeRef.current?.acknowledgeMark(markName),
|
||||
onTranscript: (role, text, isFinal) => {
|
||||
if (!isFinal) {
|
||||
return;
|
||||
}
|
||||
appendBounded(
|
||||
role === "user" ? finalUserTranscripts : finalAssistantTranscripts,
|
||||
text,
|
||||
8,
|
||||
);
|
||||
maybeResolveRoundtrip();
|
||||
},
|
||||
onEvent: (event) => {
|
||||
appendBounded(events, `${event.direction}:${event.type}`, 80);
|
||||
if (event.direction === "server" && event.type === "response.done") {
|
||||
responseDone = true;
|
||||
maybeResolveRoundtrip();
|
||||
}
|
||||
},
|
||||
onError: (error) => resolveRoundtrip?.(error),
|
||||
onClose: (reason) => {
|
||||
if (!closed && reason === "error") {
|
||||
resolveRoundtrip?.(new Error("OpenAI audio roundtrip bridge closed with an error"));
|
||||
}
|
||||
},
|
||||
});
|
||||
bridgeRef.current = bridge;
|
||||
|
||||
let chunksSent = 0;
|
||||
try {
|
||||
await bridge.connect();
|
||||
const boundedRoundtrip = withTimeout({
|
||||
label: `OpenAI audio roundtrip cycle ${cycle}`,
|
||||
timeoutMs: OPENAI_AUDIO_ROUNDTRIP_TIMEOUT_MS,
|
||||
run: () => roundtrip,
|
||||
});
|
||||
// Observe failures immediately; the same promise is awaited after input streaming completes.
|
||||
void boundedRoundtrip.catch(() => undefined);
|
||||
chunksSent = await sendPcmAudioInChunks(bridge, inputAudio);
|
||||
const roundtripError = await boundedRoundtrip;
|
||||
if (roundtripError) {
|
||||
throw roundtripError;
|
||||
}
|
||||
} catch (error) {
|
||||
resolveRoundtrip?.(error instanceof Error ? error : new Error(String(error)));
|
||||
throw error;
|
||||
} finally {
|
||||
closed = true;
|
||||
bridge.close();
|
||||
bridge.close();
|
||||
bridgeRef.current = undefined;
|
||||
await delay(100);
|
||||
}
|
||||
if (lateAudioBytes > 0) {
|
||||
throw new Error(
|
||||
`OpenAI audio roundtrip cycle ${cycle} received ${lateAudioBytes} audio bytes after close`,
|
||||
);
|
||||
}
|
||||
cycles.push({
|
||||
cycle,
|
||||
inputAudioBytes: inputAudio.byteLength,
|
||||
chunksSent,
|
||||
outputAudioBytes,
|
||||
userTranscript: finalUserTranscripts.join(" "),
|
||||
assistantTranscript: finalAssistantTranscripts.join(" "),
|
||||
responseDone,
|
||||
lateAudioBytes,
|
||||
events,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
name: "openai-backend-audio-roundtrip",
|
||||
ok: cycles.length === cycleCount,
|
||||
details: {
|
||||
model: OPENAI_REALTIME_MODEL,
|
||||
cycles,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
name: "openai-backend-audio-roundtrip",
|
||||
ok: false,
|
||||
details: {
|
||||
model: OPENAI_REALTIME_MODEL,
|
||||
completedCycles: cycles.length,
|
||||
error: shortError(error),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function smokeOpenAIWebRtc(browser: Browser, apiKey: string): Promise<SmokeResult> {
|
||||
try {
|
||||
const openAIHttpTimeoutMs = resolveOpenAIHttpTimeoutMs();
|
||||
@@ -647,10 +903,8 @@ async function smokeGatewayRelayBrowser(browser: Browser): Promise<SmokeResult>
|
||||
const dir = await mkdtemp(path.join(tmpdir(), "openclaw-realtime-talk-"));
|
||||
try {
|
||||
const { createServer } = await import("vite");
|
||||
const repoRoot = process.cwd().replaceAll("\\", "/");
|
||||
const relayModulePath = JSON.stringify(
|
||||
`/@fs/${repoRoot}/ui/src/ui/chat/realtime-talk-gateway-relay.ts`,
|
||||
);
|
||||
const repoRoot = process.cwd();
|
||||
const relayModulePath = JSON.stringify(resolveGatewayRelayModulePath(repoRoot));
|
||||
await writeFile(
|
||||
path.join(dir, "index.html"),
|
||||
'<!doctype html><meta charset="utf-8"><script type="module" src="/main.ts"></script>',
|
||||
@@ -658,8 +912,6 @@ async function smokeGatewayRelayBrowser(browser: Browser): Promise<SmokeResult>
|
||||
await writeFile(
|
||||
path.join(dir, "main.ts"),
|
||||
`
|
||||
const { GatewayRelayRealtimeTalkTransport } = await import(${relayModulePath});
|
||||
|
||||
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const listeners = new Set();
|
||||
const requests = [];
|
||||
@@ -699,6 +951,7 @@ const client = {
|
||||
};
|
||||
|
||||
try {
|
||||
const { GatewayRelayRealtimeTalkTransport } = await import(${relayModulePath});
|
||||
const transport = new GatewayRelayRealtimeTalkTransport(
|
||||
{
|
||||
provider: "smoke",
|
||||
@@ -761,9 +1014,14 @@ try {
|
||||
`,
|
||||
);
|
||||
server = await createServer({
|
||||
configFile: path.join(repoRoot, "ui/vite.config.ts"),
|
||||
root: dir,
|
||||
logLevel: "silent",
|
||||
server: { host: "127.0.0.1", port: 0 },
|
||||
server: {
|
||||
host: "127.0.0.1",
|
||||
port: 0,
|
||||
fs: { allow: [dir, repoRoot] },
|
||||
},
|
||||
});
|
||||
await server.listen();
|
||||
const address = server.httpServer?.address();
|
||||
@@ -863,6 +1121,7 @@ async function main(argv = process.argv.slice(2)): Promise<void> {
|
||||
});
|
||||
} else {
|
||||
results.push(await smokeOpenAIBackendBridge(openAIKey));
|
||||
results.push(await smokeOpenAIAudioRoundtrip(openAIKey, cli.openAIAudioCycles));
|
||||
results.push(await smokeOpenAIWebRtc(browser, openAIKey));
|
||||
}
|
||||
if (!cli.openAIOnly) {
|
||||
@@ -901,7 +1160,10 @@ export const testing = {
|
||||
parseRealtimeSmokeArgs,
|
||||
readOpenAIRealtimeBrowserResponseText,
|
||||
readBoundedText,
|
||||
resolveGatewayRelayModulePath,
|
||||
resolveOpenAIHttpTimeoutMs,
|
||||
sendPcmAudioInChunks,
|
||||
transcriptIncludesMarker,
|
||||
usage,
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { DockerReleaseChannel } from "./lib/docker-release-policy.mjs";
|
||||
|
||||
export type DockerChannelPromotion = {
|
||||
image: string;
|
||||
sourceRef: string;
|
||||
targetRefs: string[];
|
||||
};
|
||||
|
||||
export type DockerChannelPromotionPlan = {
|
||||
channel: DockerReleaseChannel;
|
||||
promotions: DockerChannelPromotion[];
|
||||
version: string;
|
||||
};
|
||||
|
||||
export function createDockerChannelPromotionPlan(params: {
|
||||
version: string;
|
||||
images: string[];
|
||||
}): DockerChannelPromotionPlan;
|
||||
|
||||
export function promoteDockerChannel(
|
||||
params: { version: string; images: string[] },
|
||||
options?: {
|
||||
allowRollback?: boolean;
|
||||
execFileSyncImpl?: (command: string, args: string[], options: object) => string;
|
||||
log?: (message: string) => void;
|
||||
verifyAttestationsImpl?: (params: {
|
||||
imageRefs: string[];
|
||||
requiredPlatforms: Array<{
|
||||
architecture: string;
|
||||
os: string;
|
||||
variant?: string;
|
||||
}>;
|
||||
execFileSyncImpl: (command: string, args: string[], options: object) => string;
|
||||
log: (message: string) => void;
|
||||
}) => void;
|
||||
},
|
||||
): DockerChannelPromotionPlan;
|
||||
@@ -0,0 +1,288 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import process from "node:process";
|
||||
import { parseArgs } from "node:util";
|
||||
import { isDirectRunUrl } from "./lib/direct-run.mjs";
|
||||
import { resolveDockerReleasePolicy } from "./lib/docker-release-policy.mjs";
|
||||
import { compareReleaseVersions } from "./lib/release-version.mjs";
|
||||
import { parsePlatform, verifyDockerAttestations } from "./verify-docker-attestations.mjs";
|
||||
|
||||
const DOCKER_TIMEOUT_MS = 120_000;
|
||||
const REQUIRED_PLATFORMS = Object.freeze([
|
||||
parsePlatform("linux/amd64"),
|
||||
parsePlatform("linux/arm64"),
|
||||
]);
|
||||
const VARIANTS = Object.freeze([
|
||||
{ aliasKey: "default", suffix: "" },
|
||||
{ aliasKey: "slim", suffix: "-slim" },
|
||||
{ aliasKey: "browser", suffix: "-browser" },
|
||||
]);
|
||||
|
||||
/** Build the version-specific source to moving-alias promotion plan. */
|
||||
export function createDockerChannelPromotionPlan({ version, images }) {
|
||||
if (images.length === 0) {
|
||||
throw new Error("At least one --image is required.");
|
||||
}
|
||||
const policy = resolveDockerReleasePolicy(version);
|
||||
const promotions = [];
|
||||
for (const image of images) {
|
||||
for (const { aliasKey, suffix } of VARIANTS) {
|
||||
const aliases = policy.movingAliases[aliasKey];
|
||||
if (aliases.length === 0) {
|
||||
continue;
|
||||
}
|
||||
promotions.push({
|
||||
image,
|
||||
sourceRef: `${image}:${version}${suffix}`,
|
||||
targetRefs: aliases.map((alias) => `${image}:${alias}`),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (promotions.length === 0) {
|
||||
throw new Error(`Docker ${policy.channel} releases have no moving aliases to promote.`);
|
||||
}
|
||||
return { channel: policy.channel, promotions, version: policy.version };
|
||||
}
|
||||
|
||||
function runDocker(args, execFileSyncImpl) {
|
||||
return execFileSyncImpl("docker", args, {
|
||||
encoding: "utf8",
|
||||
killSignal: "SIGKILL",
|
||||
maxBuffer: 20 * 1024 * 1024,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
timeout: DOCKER_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
|
||||
function inspectManifestDigest(imageRef, execFileSyncImpl) {
|
||||
const raw = runDocker(
|
||||
["buildx", "imagetools", "inspect", imageRef, "--format", "{{json .Manifest}}"],
|
||||
execFileSyncImpl,
|
||||
);
|
||||
let digest;
|
||||
try {
|
||||
digest = JSON.parse(raw).digest;
|
||||
} catch (error) {
|
||||
throw new Error(`Could not parse the manifest for ${imageRef}.`, { cause: error });
|
||||
}
|
||||
if (typeof digest !== "string" || !/^sha256:[a-f0-9]{64}$/.test(digest)) {
|
||||
throw new Error(`The manifest for ${imageRef} did not contain a valid sha256 digest.`);
|
||||
}
|
||||
return digest;
|
||||
}
|
||||
|
||||
function formatCommandError(error) {
|
||||
if (!(error instanceof Error)) {
|
||||
return String(error);
|
||||
}
|
||||
const output = [error.message];
|
||||
for (const field of ["stderr", "stdout"]) {
|
||||
const value = error[field];
|
||||
if (typeof value === "string") {
|
||||
output.push(value);
|
||||
} else if (Buffer.isBuffer(value)) {
|
||||
output.push(value.toString("utf8"));
|
||||
}
|
||||
}
|
||||
return output.join("\n");
|
||||
}
|
||||
|
||||
function isMissingManifestError(error) {
|
||||
const message = formatCommandError(error);
|
||||
return /(?:manifest unknown|no such manifest|:\s*not found(?:\s|$))/i.test(message);
|
||||
}
|
||||
|
||||
function formatPlatform(platform) {
|
||||
const suffix = platform.variant ? `/${platform.variant}` : "";
|
||||
return `${platform.os}/${platform.architecture}${suffix}`;
|
||||
}
|
||||
|
||||
function inspectImageVersion(imageRef, execFileSyncImpl, { allowMissing = false } = {}) {
|
||||
const versions = new Map();
|
||||
for (const [index, platform] of REQUIRED_PLATFORMS.entries()) {
|
||||
const platformName = formatPlatform(platform);
|
||||
let raw;
|
||||
try {
|
||||
// In formatted multi-platform inspection, Buildx keys .Image by os/arch.
|
||||
// Read every promoted platform rather than trusting one config label.
|
||||
raw = runDocker(
|
||||
[
|
||||
"buildx",
|
||||
"imagetools",
|
||||
"inspect",
|
||||
imageRef,
|
||||
"--format",
|
||||
`{{json (index .Image "${platformName}")}}`,
|
||||
],
|
||||
execFileSyncImpl,
|
||||
);
|
||||
} catch (error) {
|
||||
if (allowMissing && index === 0 && isMissingManifestError(error)) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
let version;
|
||||
try {
|
||||
version = JSON.parse(raw)?.config?.Labels?.["org.opencontainers.image.version"];
|
||||
} catch (error) {
|
||||
throw new Error(`Could not parse the ${platformName} image config for ${imageRef}.`, {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
if (typeof version !== "string" || version.trim().length === 0) {
|
||||
throw new Error(
|
||||
`${imageRef} does not have an org.opencontainers.image.version label for ${platformName}.`,
|
||||
);
|
||||
}
|
||||
versions.set(platformName, version.trim());
|
||||
}
|
||||
const uniqueVersions = new Set(versions.values());
|
||||
if (uniqueVersions.size !== 1) {
|
||||
const details = [...versions].map(([platform, version]) => `${platform}=${version}`).join(", ");
|
||||
throw new Error(`${imageRef} has inconsistent platform versions: ${details}.`);
|
||||
}
|
||||
return uniqueVersions.values().next().value;
|
||||
}
|
||||
|
||||
function verifySourceVersions(resolved, version, execFileSyncImpl) {
|
||||
for (const promotion of resolved) {
|
||||
const sourceVersion = inspectImageVersion(promotion.sourceDigestRef, execFileSyncImpl);
|
||||
if (sourceVersion !== version) {
|
||||
throw new Error(
|
||||
`${promotion.sourceDigestRef} reports version ${sourceVersion}, expected ${version}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function preventChannelRollback(resolved, version, execFileSyncImpl) {
|
||||
for (const promotion of resolved) {
|
||||
for (const targetRef of promotion.targetRefs) {
|
||||
const currentVersion = inspectImageVersion(targetRef, execFileSyncImpl, {
|
||||
allowMissing: true,
|
||||
});
|
||||
if (currentVersion === null) {
|
||||
continue;
|
||||
}
|
||||
const comparison = compareReleaseVersions(version, currentVersion);
|
||||
if (comparison === null) {
|
||||
throw new Error(
|
||||
`Cannot compare candidate version ${version} with ${targetRef} version ${currentVersion}.`,
|
||||
);
|
||||
}
|
||||
if (comparison < 0) {
|
||||
throw new Error(
|
||||
`Refusing to move ${targetRef} backward from ${currentVersion} to ${version}. ` +
|
||||
"An approved repair may rerun with --allow-rollback.",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Promote every planned alias and verify the registry result. */
|
||||
export function promoteDockerChannel({ version, images }, options = {}) {
|
||||
const execFileSyncImpl = options.execFileSyncImpl ?? execFileSync;
|
||||
const log = options.log ?? console.log;
|
||||
const verifyAttestationsImpl = options.verifyAttestationsImpl ?? verifyDockerAttestations;
|
||||
const plan = createDockerChannelPromotionPlan({ version, images });
|
||||
|
||||
// Resolve every version-specific source before the first alias write. A missing
|
||||
// release variant must not leave the channel partially promoted.
|
||||
const resolved = plan.promotions.map((promotion) => {
|
||||
const sourceDigest = inspectManifestDigest(promotion.sourceRef, execFileSyncImpl);
|
||||
return {
|
||||
...promotion,
|
||||
sourceDigest,
|
||||
sourceDigestRef: `${promotion.image}@${sourceDigest}`,
|
||||
};
|
||||
});
|
||||
|
||||
// Attestation checks and writes share these digest refs so a concurrent tag
|
||||
// rewrite cannot swap the content between verification and promotion.
|
||||
verifyAttestationsImpl({
|
||||
imageRefs: resolved.map((promotion) => promotion.sourceDigestRef),
|
||||
requiredPlatforms: REQUIRED_PLATFORMS,
|
||||
execFileSyncImpl,
|
||||
log,
|
||||
});
|
||||
verifySourceVersions(resolved, plan.version, execFileSyncImpl);
|
||||
if (!options.allowRollback) {
|
||||
preventChannelRollback(resolved, plan.version, execFileSyncImpl);
|
||||
}
|
||||
|
||||
for (const promotion of resolved) {
|
||||
const targetArgs = promotion.targetRefs.flatMap((targetRef) => ["--tag", targetRef]);
|
||||
runDocker(
|
||||
[
|
||||
"buildx",
|
||||
"imagetools",
|
||||
"create",
|
||||
"--prefer-index=false",
|
||||
...targetArgs,
|
||||
promotion.sourceDigestRef,
|
||||
],
|
||||
execFileSyncImpl,
|
||||
);
|
||||
for (const targetRef of promotion.targetRefs) {
|
||||
const targetDigest = inspectManifestDigest(targetRef, execFileSyncImpl);
|
||||
if (targetDigest !== promotion.sourceDigest) {
|
||||
throw new Error(
|
||||
`${targetRef} resolved to ${targetDigest}, expected ${promotion.sourceDigest}.`,
|
||||
);
|
||||
}
|
||||
log(`Verified ${targetRef} -> ${promotion.sourceDigest}.`);
|
||||
}
|
||||
}
|
||||
return plan;
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
console.log(
|
||||
"Usage: node scripts/docker-channel-promote.mjs --version YYYY.M.P --image REGISTRY/IMAGE [--image REGISTRY/IMAGE] [--allow-rollback]",
|
||||
);
|
||||
}
|
||||
|
||||
function main() {
|
||||
const { values } = parseArgs({
|
||||
args: process.argv.slice(2),
|
||||
options: {
|
||||
"allow-rollback": { type: "boolean" },
|
||||
help: { type: "boolean", short: "h" },
|
||||
image: { type: "string", multiple: true },
|
||||
version: { type: "string" },
|
||||
},
|
||||
strict: true,
|
||||
});
|
||||
if (values.help) {
|
||||
printHelp();
|
||||
return;
|
||||
}
|
||||
const version = values.version?.trim();
|
||||
if (!version) {
|
||||
throw new Error("--version is required.");
|
||||
}
|
||||
const images = (values.image ?? []).map((image) => image.trim());
|
||||
if (images.length === 0 || images.some((image) => image.length === 0)) {
|
||||
throw new Error("At least one non-empty --image is required.");
|
||||
}
|
||||
const plan = promoteDockerChannel(
|
||||
{ version, images },
|
||||
{ allowRollback: values["allow-rollback"] },
|
||||
);
|
||||
console.log(`Promoted Docker ${plan.channel} aliases for ${plan.version}.`);
|
||||
}
|
||||
|
||||
if (isDirectRunUrl(process.argv[1], import.meta.url)) {
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`docker-channel-promote: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -417,10 +417,14 @@ function downloadDockerArtifacts(runId, repo, outputDir) {
|
||||
if (names.length === 0) {
|
||||
throw new Error(`No docker-e2e-* artifacts found for run ${runId}`);
|
||||
}
|
||||
for (const name of names) {
|
||||
for (const [index, name] of names.entries()) {
|
||||
const artifactDir = path.join(
|
||||
outputDir,
|
||||
`${String(index).padStart(3, "0")}-${safePathSegment(name)}`,
|
||||
);
|
||||
run(
|
||||
"gh",
|
||||
["run", "download", String(runId), "--repo", repo, "--name", name, "--dir", outputDir],
|
||||
["run", "download", String(runId), "--repo", repo, "--name", name, "--dir", artifactDir],
|
||||
{
|
||||
stdio: "inherit",
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
FROM node:24-bookworm-slim@sha256:242549cd46785b480c832479a730f4f2a20865d61ea2e404fdb2a5c3d3b73ecf
|
||||
FROM node:24-bookworm-slim@sha256:6f7b03f7c2c8e2e784dcf9295400527b9b1270fd37b7e9a7285cf83b6951452d
|
||||
|
||||
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
|
||||
|
||||
@@ -20,6 +20,7 @@ COPY packages ./packages
|
||||
COPY extensions ./extensions
|
||||
COPY patches ./patches
|
||||
COPY scripts/postinstall-bundled-plugins.mjs scripts/preinstall-package-manager-warning.mjs scripts/prepare-git-hooks.mjs scripts/npm-runner.mjs scripts/windows-cmd-helpers.mjs ./scripts/
|
||||
COPY scripts/lib/guard-inventory-utils.mjs ./scripts/lib/guard-inventory-utils.mjs
|
||||
COPY scripts/lib/package-dist-imports.mjs ./scripts/lib/package-dist-imports.mjs
|
||||
RUN --mount=type=cache,id=openclaw-pnpm-store,target=/root/.local/share/pnpm/store,sharing=locked \
|
||||
corepack enable \
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
FROM node:24-bookworm-slim@sha256:242549cd46785b480c832479a730f4f2a20865d61ea2e404fdb2a5c3d3b73ecf
|
||||
FROM node:24-bookworm-slim@sha256:6f7b03f7c2c8e2e784dcf9295400527b9b1270fd37b7e9a7285cf83b6951452d
|
||||
|
||||
RUN --mount=type=cache,id=openclaw-install-sh-e2e-apt-cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,id=openclaw-install-sh-e2e-apt-lists,target=/var/lib/apt,sharing=locked \
|
||||
|
||||
@@ -855,7 +855,6 @@ run_profile() {
|
||||
test -f "$workspace/IDENTITY.md"
|
||||
test -f "$workspace/USER.md"
|
||||
test -f "$workspace/SOUL.md"
|
||||
test -f "$workspace/TOOLS.md"
|
||||
# The remaining checks are deterministic tool smokes, not the interactive
|
||||
# first-run identity ritual. Drop BOOTSTRAP.md so provider prompts stay focused
|
||||
# on the fixture task and do not spend turns following onboarding copy.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
FROM node:24-bookworm-slim@sha256:242549cd46785b480c832479a730f4f2a20865d61ea2e404fdb2a5c3d3b73ecf
|
||||
FROM node:24-bookworm-slim@sha256:6f7b03f7c2c8e2e784dcf9295400527b9b1270fd37b7e9a7285cf83b6951452d
|
||||
|
||||
# Smoke images are pinned and short-lived, so skip distro upgrades here and
|
||||
# spend the time budget on installer coverage instead.
|
||||
|
||||
+13
-5
@@ -409,9 +409,16 @@ contains_disallowed_chars() {
|
||||
[[ "$value" == *$'\n'* || "$value" == *$'\r'* || "$value" == *$'\t'* ]]
|
||||
}
|
||||
|
||||
is_valid_timezone() {
|
||||
is_valid_timezone_in_image() {
|
||||
local value="$1"
|
||||
[[ -e "/usr/share/zoneinfo/$value" && ! -d "/usr/share/zoneinfo/$value" ]]
|
||||
docker run --rm --network none --entrypoint node "$IMAGE_NAME" -e '
|
||||
const timezone = process.argv[1];
|
||||
try {
|
||||
new Intl.DateTimeFormat("en", { timeZone: timezone }).format(0);
|
||||
} catch {
|
||||
process.exit(1);
|
||||
}
|
||||
' "$value"
|
||||
}
|
||||
|
||||
validate_mount_path_value() {
|
||||
@@ -497,9 +504,6 @@ if [[ -n "$TIMEZONE" ]]; then
|
||||
if [[ ! "$TIMEZONE" =~ ^[A-Za-z0-9/_+\-]+$ ]]; then
|
||||
fail "OPENCLAW_TZ must be a valid IANA timezone string (e.g. Asia/Shanghai)."
|
||||
fi
|
||||
if ! is_valid_timezone "$TIMEZONE"; then
|
||||
fail "OPENCLAW_TZ must match a timezone in /usr/share/zoneinfo (e.g. Asia/Shanghai)."
|
||||
fi
|
||||
fi
|
||||
|
||||
mkdir -p "$OPENCLAW_CONFIG_DIR"
|
||||
@@ -782,6 +786,10 @@ else
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -n "$TIMEZONE" ]] && ! is_valid_timezone_in_image "$TIMEZONE"; then
|
||||
fail "OPENCLAW_TZ must be supported by $IMAGE_NAME (e.g. Asia/Shanghai)."
|
||||
fi
|
||||
|
||||
# Ensure bind-mounted data directories are writable by the container's `node`
|
||||
# user (uid 1000). Host-created dirs inherit the host user's uid which may
|
||||
# differ, causing EACCES when the container tries to mkdir/write.
|
||||
|
||||
@@ -60,13 +60,14 @@ func translateDocBodyChunked(ctx context.Context, translator docsTranslator, rel
|
||||
mapping := map[string]string{}
|
||||
maskedBody := maskMarkdownFencedLiterals(body, placeholderState.Next, &placeholders, mapping)
|
||||
maskedBody = maskMarkdownDocSyntax(maskedBody, placeholderState.Next, &placeholders, mapping)
|
||||
listPlaceholders := maskedListMarkerPlaceholders(mapping)
|
||||
blocks := splitDocBodyIntoBlocks(maskedBody)
|
||||
groups := groupDocBlocks(blocks, docsI18nDocChunkMaxBytes())
|
||||
logDocChunkPlan(relPath, blocks, groups)
|
||||
out := strings.Builder{}
|
||||
for index, group := range groups {
|
||||
chunkID := fmt.Sprintf("%s.chunk-%03d", relPath, index+1)
|
||||
translated, err := translateDocBlockGroup(ctx, translator, chunkID, group, placeholders, srcLang, tgtLang)
|
||||
translated, err := translateDocBlockGroup(ctx, translator, chunkID, group, placeholders, listPlaceholders, srcLang, tgtLang)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -74,11 +75,25 @@ func translateDocBodyChunked(ctx context.Context, translator docsTranslator, rel
|
||||
}
|
||||
translatedBody := out.String()
|
||||
translatedBody = normalizeMaskedListMarkerPlaceholders(translatedBody, mapping)
|
||||
translatedBody = normalizeMaskedListMarkerSpacing(maskedBody, translatedBody, listPlaceholders)
|
||||
translatedBody = escapeUnexpectedListItemBodyMarkers(maskedBody, translatedBody, listPlaceholders)
|
||||
translatedBody = escapeUnexpectedMarkdownListMarkers(translatedBody, listPlaceholders)
|
||||
if err := validatePlaceholders(translatedBody, placeholders); err != nil {
|
||||
return "", fmt.Errorf("%s: restore fenced literals: %w", relPath, err)
|
||||
}
|
||||
maskedListMarkers := extractMarkdownListMarkerPrefixes(translatedBody)
|
||||
translatedBody = unmaskMarkdown(translatedBody, placeholders, mapping)
|
||||
if err := validateDocBodyFencedLiterals(body, translatedBody); err != nil {
|
||||
log.Printf(
|
||||
"docs-i18n: final list diagnostics %s source=%q masked=%q translated=%q",
|
||||
relPath,
|
||||
extractMarkdownListMarkerPrefixes(body),
|
||||
maskedListMarkers,
|
||||
extractMarkdownListMarkerPrefixes(translatedBody),
|
||||
)
|
||||
if os.Getenv("OPENCLAW_DOCS_I18N_LOG_REJECTED_BODY") == "1" {
|
||||
log.Printf("docs-i18n: rejected translated body %s %q", relPath, translatedBody)
|
||||
}
|
||||
return "", fmt.Errorf("%s: final document validation: %w", relPath, err)
|
||||
}
|
||||
return translatedBody, nil
|
||||
@@ -126,14 +141,14 @@ func validateDocBodyFencedLiterals(source, translated string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func translateDocBlockGroup(ctx context.Context, translator docsTranslator, chunkID string, blocks []string, protectedPlaceholders []string, srcLang, tgtLang string) (string, error) {
|
||||
func translateDocBlockGroup(ctx context.Context, translator docsTranslator, chunkID string, blocks []string, protectedPlaceholders []string, listPlaceholders map[string]string, srcLang, tgtLang string) (string, error) {
|
||||
source := strings.Join(blocks, "")
|
||||
if strings.TrimSpace(source) == "" {
|
||||
return source, nil
|
||||
}
|
||||
if plan, ok := planDocChunkSplit(blocks, docsI18nDocChunkMaxBytes(), docsI18nDocChunkPromptBudget()); ok {
|
||||
logDocChunkPlanSplit(chunkID, plan, source)
|
||||
return translatePlannedDocChunkGroups(ctx, translator, chunkID, source, plan.groups, protectedPlaceholders, srcLang, tgtLang)
|
||||
return translatePlannedDocChunkGroups(ctx, translator, chunkID, source, plan.groups, protectedPlaceholders, listPlaceholders, srcLang, tgtLang)
|
||||
}
|
||||
normalizedSource, commonIndent := stripCommonIndent(source)
|
||||
log.Printf("docs-i18n: chunk start %s blocks=%d bytes=%d", chunkID, len(blocks), len(source))
|
||||
@@ -145,6 +160,11 @@ func translateDocBlockGroup(ctx context.Context, translator docsTranslator, chun
|
||||
translated = sanitizeDocChunkProtocolWrappers(source, translated)
|
||||
translated = preserveDocChunkBoundaryWhitespace(normalizedSource, translated)
|
||||
translated = reapplyCommonIndent(translated, commonIndent)
|
||||
translated = normalizeMaskedListMarkerPlaceholders(translated, listPlaceholders)
|
||||
translated = normalizeMaskedListMarkerSpacing(source, translated, listPlaceholders)
|
||||
translated = escapeUnexpectedListItemBodyMarkers(source, translated, listPlaceholders)
|
||||
translated = escapeUnexpectedMarkdownListMarkers(translated, listPlaceholders)
|
||||
translated = unwrapUnexpectedInlineCodeSpans(source, translated)
|
||||
if validationErr := validateDocChunkTranslation(source, translated); validationErr == nil {
|
||||
log.Printf("docs-i18n: chunk done %s out_bytes=%d", chunkID, len(translated))
|
||||
return translated, nil
|
||||
@@ -153,27 +173,27 @@ func translateDocBlockGroup(ctx context.Context, translator docsTranslator, chun
|
||||
}
|
||||
}
|
||||
if len(blocks) <= 1 {
|
||||
if fallback, fallbackErr := translateDocLeafBlock(ctx, translator, chunkID, source, protectedPlaceholders, srcLang, tgtLang); fallbackErr == nil {
|
||||
if fallback, fallbackErr := translateDocLeafBlock(ctx, translator, chunkID, source, protectedPlaceholders, listPlaceholders, srcLang, tgtLang); fallbackErr == nil {
|
||||
return fallback, nil
|
||||
}
|
||||
if plan, ok := planSingletonDocChunkRetry(source, docsI18nDocChunkMaxBytes(), docsI18nDocChunkPromptBudget()); ok {
|
||||
logDocChunkPlanSplit(chunkID, plan, source)
|
||||
return translatePlannedDocChunkGroups(ctx, translator, chunkID, source, plan.groups, protectedPlaceholders, srcLang, tgtLang)
|
||||
return translatePlannedDocChunkGroups(ctx, translator, chunkID, source, plan.groups, protectedPlaceholders, listPlaceholders, srcLang, tgtLang)
|
||||
}
|
||||
return "", fmt.Errorf("%s: %w", chunkID, err)
|
||||
}
|
||||
if plan, ok := planDocChunkSplit(blocks, docsI18nDocChunkMaxBytes(), docsI18nDocChunkPromptBudget()); ok {
|
||||
logDocChunkSplit(chunkID, len(blocks), err)
|
||||
return translatePlannedDocChunkGroups(ctx, translator, chunkID, source, plan.groups, protectedPlaceholders, srcLang, tgtLang)
|
||||
return translatePlannedDocChunkGroups(ctx, translator, chunkID, source, plan.groups, protectedPlaceholders, listPlaceholders, srcLang, tgtLang)
|
||||
}
|
||||
if plan, ok := splitDocChunkBlocksMidpointSimple(blocks); ok {
|
||||
logDocChunkSplit(chunkID, len(blocks), err)
|
||||
return translatePlannedDocChunkGroups(ctx, translator, chunkID, source, plan.groups, protectedPlaceholders, srcLang, tgtLang)
|
||||
return translatePlannedDocChunkGroups(ctx, translator, chunkID, source, plan.groups, protectedPlaceholders, listPlaceholders, srcLang, tgtLang)
|
||||
}
|
||||
return "", fmt.Errorf("%s: %w", chunkID, err)
|
||||
}
|
||||
|
||||
func translateDocLeafBlock(ctx context.Context, translator docsTranslator, chunkID, source string, protectedPlaceholders []string, srcLang, tgtLang string) (string, error) {
|
||||
func translateDocLeafBlock(ctx context.Context, translator docsTranslator, chunkID, source string, protectedPlaceholders []string, listPlaceholders map[string]string, srcLang, tgtLang string) (string, error) {
|
||||
sourceStructure := summarizeDocChunkStructure(source)
|
||||
if sourceStructure.fenceCount != 0 {
|
||||
return "", fmt.Errorf("%s: raw leaf fallback not applicable", chunkID)
|
||||
@@ -194,6 +214,11 @@ func translateDocLeafBlock(ctx context.Context, translator docsTranslator, chunk
|
||||
translated = sanitizeDocChunkProtocolWrappers(source, translated)
|
||||
translated = preserveDocChunkBoundaryWhitespace(normalizedSource, translated)
|
||||
translated = reapplyCommonIndent(translated, commonIndent)
|
||||
translated = normalizeMaskedListMarkerPlaceholders(translated, listPlaceholders)
|
||||
translated = normalizeMaskedListMarkerSpacing(source, translated, listPlaceholders)
|
||||
translated = escapeUnexpectedListItemBodyMarkers(source, translated, listPlaceholders)
|
||||
translated = escapeUnexpectedMarkdownListMarkers(translated, listPlaceholders)
|
||||
translated = unwrapUnexpectedInlineCodeSpans(source, translated)
|
||||
if validationErr := validateDocChunkTranslation(source, translated); validationErr != nil {
|
||||
return "", validationErr
|
||||
}
|
||||
@@ -733,11 +758,11 @@ func containsProtocolWrapperToken(text string) bool {
|
||||
return strings.Contains(lower, strings.ToLower(bodyTagStart)) || strings.Contains(lower, strings.ToLower(frontmatterTagStart))
|
||||
}
|
||||
|
||||
func translatePlannedDocChunkGroups(ctx context.Context, translator docsTranslator, chunkID, source string, groups [][]string, protectedPlaceholders []string, srcLang, tgtLang string) (string, error) {
|
||||
func translatePlannedDocChunkGroups(ctx context.Context, translator docsTranslator, chunkID, source string, groups [][]string, protectedPlaceholders []string, listPlaceholders map[string]string, srcLang, tgtLang string) (string, error) {
|
||||
var out strings.Builder
|
||||
translatedGroups := make([]string, 0, len(groups))
|
||||
for index, group := range groups {
|
||||
translated, err := translateDocBlockGroup(ctx, translator, fmt.Sprintf("%s.%02d", chunkID, index+1), group, protectedPlaceholders, srcLang, tgtLang)
|
||||
translated, err := translateDocBlockGroup(ctx, translator, fmt.Sprintf("%s.%02d", chunkID, index+1), group, protectedPlaceholders, listPlaceholders, srcLang, tgtLang)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -294,12 +294,36 @@ func (t *docSyntaxMaskingTranslator) Translate(_ context.Context, text, _, _ str
|
||||
func (t *docSyntaxMaskingTranslator) TranslateRaw(_ context.Context, text, _, _ string) (string, error) {
|
||||
t.rawInputs = append(t.rawInputs, text)
|
||||
translated := strings.ReplaceAll(text, "Visible prose", "Видимый текст")
|
||||
translated = regexp.MustCompile(`(?m)^(__OC_I18N_\d+__)`).ReplaceAllString(translated, " $1")
|
||||
translated = regexp.MustCompile(`(?m)^(__OC_I18N_\d+__)`).ReplaceAllString(translated, " 1. $1")
|
||||
return translated, nil
|
||||
}
|
||||
|
||||
func (t *docSyntaxMaskingTranslator) Close() {}
|
||||
|
||||
type accidentalListMarkerTranslator struct{}
|
||||
|
||||
func (accidentalListMarkerTranslator) Translate(_ context.Context, text, _, _ string) (string, error) {
|
||||
return text, nil
|
||||
}
|
||||
|
||||
func (accidentalListMarkerTranslator) TranslateRaw(_ context.Context, text, _, _ string) (string, error) {
|
||||
return strings.ReplaceAll(text, "September begins the standard rate.", "1. September beginnt der Standardtarif."), nil
|
||||
}
|
||||
|
||||
func (accidentalListMarkerTranslator) Close() {}
|
||||
|
||||
type translatedOrdinalTranslator struct{}
|
||||
|
||||
func (translatedOrdinalTranslator) Translate(_ context.Context, text, _, _ string) (string, error) {
|
||||
return text, nil
|
||||
}
|
||||
|
||||
func (translatedOrdinalTranslator) TranslateRaw(_ context.Context, text, _, _ string) (string, error) {
|
||||
return strings.NewReplacer("1st failure", "1. Fehler", "2nd failure", "2. Fehler").Replace(text), nil
|
||||
}
|
||||
|
||||
func (translatedOrdinalTranslator) Close() {}
|
||||
|
||||
type duplicateFirstFencedPlaceholderTranslator struct {
|
||||
rawCalls int
|
||||
}
|
||||
@@ -746,6 +770,8 @@ func TestNormalizeMaskedListMarkerPlaceholdersRemovesAddedContainers(t *testing.
|
||||
" __OC_I18N_000001__Top level",
|
||||
"> __OC_I18N_000002__Nested",
|
||||
" > __OC_I18N_000003__Quoted",
|
||||
"1. __OC_I18N_000001__Numbered wrapper",
|
||||
" - __OC_I18N_000002__Bullet wrapper",
|
||||
" __OC_I18N_000004__ prose",
|
||||
"",
|
||||
}, "\n")
|
||||
@@ -753,6 +779,8 @@ func TestNormalizeMaskedListMarkerPlaceholdersRemovesAddedContainers(t *testing.
|
||||
"__OC_I18N_000001__Top level",
|
||||
"__OC_I18N_000002__Nested",
|
||||
"__OC_I18N_000003__Quoted",
|
||||
"__OC_I18N_000001__Numbered wrapper",
|
||||
"__OC_I18N_000002__Bullet wrapper",
|
||||
" __OC_I18N_000004__ prose",
|
||||
"",
|
||||
}, "\n")
|
||||
@@ -762,6 +790,70 @@ func TestNormalizeMaskedListMarkerPlaceholdersRemovesAddedContainers(t *testing.
|
||||
}
|
||||
}
|
||||
|
||||
func TestEscapeUnexpectedMarkdownListMarkersPreservesFencedExamples(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
translated := strings.Join([]string{
|
||||
"1. September beginnt der Standardtarif.",
|
||||
"- Unbeabsichtigter Aufzählungspunkt.",
|
||||
"> 2) Verschachtelte Nummerierung.",
|
||||
"- __OC_I18N_000001__Maskierter Listeneintrag.",
|
||||
"3. September mit __OC_I18N_000002__Inlinecode.",
|
||||
"```md",
|
||||
"1. Beispiel bleibt unverändert.",
|
||||
"```",
|
||||
"",
|
||||
}, "\n")
|
||||
want := strings.Join([]string{
|
||||
`1\. September beginnt der Standardtarif.`,
|
||||
`\- Unbeabsichtigter Aufzählungspunkt.`,
|
||||
`> 2\) Verschachtelte Nummerierung.`,
|
||||
"- __OC_I18N_000001__Maskierter Listeneintrag.",
|
||||
`3\. September mit __OC_I18N_000002__Inlinecode.`,
|
||||
"```md",
|
||||
"1. Beispiel bleibt unverändert.",
|
||||
"```",
|
||||
"",
|
||||
}, "\n")
|
||||
|
||||
if got := escapeUnexpectedMarkdownListMarkers(translated, map[string]string{"__OC_I18N_000001__": "- "}); got != want {
|
||||
t.Fatalf("unexpected escaped list markers:\n%s\nwant:\n%s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeMaskedListMarkerSpacingRestoresSourceWhitespace(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
first := "__OC_I18N_000001__"
|
||||
second := "__OC_I18N_000002__"
|
||||
markers := map[string]string{first: "- ", second: " - "}
|
||||
source := "Intro.\n\n" + first + "First\n continuation\n" + second + "Second\n"
|
||||
translated := "Einleitung. " + first + "Erste\n Fortsetzung\n\n\n" + second + "Zweite\n"
|
||||
want := "Einleitung.\n\n" + first + "Erste\n Fortsetzung\n" + second + "Zweite\n"
|
||||
|
||||
if got := normalizeMaskedListMarkerSpacing(source, translated, markers); got != want {
|
||||
t.Fatalf("unexpected normalized spacing:\n%s\nwant:\n%s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranslateDocBodyChunkedEscapesTranslatedOrdinalAtListItemStart(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body := "- 1st failure: 30 seconds\n- 2nd failure: 1 minute\n- 3rd+ failure: 5 minutes\n"
|
||||
translated, err := translateDocBodyChunked(
|
||||
context.Background(), translatedOrdinalTranslator{}, "concepts/model-failover.md", body, "en", "de",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("translateDocBodyChunked returned error: %v", err)
|
||||
}
|
||||
if !strings.Contains(translated, `- 1\. Fehler: 30 seconds`) || !strings.Contains(translated, `- 2\. Fehler: 1 minute`) {
|
||||
t.Fatalf("expected translated ordinals to be escaped:\n%s", translated)
|
||||
}
|
||||
if err := validateDocBodyFencedLiterals(body, translated); err != nil {
|
||||
t.Fatalf("expected repaired final structure to validate: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDocChunkTranslationRejectsTranslatedInlineCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -799,6 +891,30 @@ func TestValidateDocChunkTranslationAcceptsReorderedInlineCode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnwrapUnexpectedInlineCodeSpans(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
source := "Use labels in ordinary prose.\n"
|
||||
translated := "Verwende `Bezeichnungen` in `normalem Text`.\n"
|
||||
want := "Verwende Bezeichnungen in normalem Text.\n"
|
||||
if got := unwrapUnexpectedInlineCodeSpans(source, translated); got != want {
|
||||
t.Fatalf("unexpected inline-code repair:\n%s\nwant:\n%s", got, want)
|
||||
}
|
||||
if err := validateDocChunkTranslation(source, want); err != nil {
|
||||
t.Fatalf("expected repaired translation to validate: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnwrapUnexpectedInlineCodeSpansPreservesSourceCodeContract(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
source := "Use `--source`.\n"
|
||||
translated := "Verwende `--target`.\n"
|
||||
if got := unwrapUnexpectedInlineCodeSpans(source, translated); got != translated {
|
||||
t.Fatalf("expected source inline-code contract to remain untouched: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaskMarkdownDocSyntaxPreservesCanonicalNestedBackticks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -1093,6 +1209,38 @@ func TestValidateDocChunkTranslationAllowsTranslatedProseInIsolatedIndentedFence
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDocChunkTranslationAllowsDedentedCodeInIndentedFence(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sourceFence := " ```typescript\n" +
|
||||
"const plugin = createPlugin<Account>({\n" +
|
||||
" // Account resolution belongs on `config`.\n" +
|
||||
" notify: (code) => `Pairing code: ${code}`,\n" +
|
||||
"});\n" +
|
||||
" ```\n"
|
||||
translatedFence := " ```typescript\n" +
|
||||
"const plugin = createPlugin<Account>({\n" +
|
||||
" // La resolución de cuentas pertenece a `config`.\n" +
|
||||
" notify: (code) => `Código de emparejamiento: ${code}`,\n" +
|
||||
"});\n" +
|
||||
" ```\n"
|
||||
|
||||
if err := validateDocChunkTranslation(sourceFence, translatedFence); err != nil {
|
||||
t.Fatalf("expected translated pure fenced chunk to validate, got %v", err)
|
||||
}
|
||||
|
||||
source := "<Example>\n" + sourceFence + " Run `openclaw doctor` after editing.\n</Example>\n"
|
||||
translated := "<Example>\n" + translatedFence + " Ejecuta `openclaw doctor` después de editar.\n</Example>\n"
|
||||
if err := validateDocChunkTranslation(source, translated); err != nil {
|
||||
t.Fatalf("expected translated component fence and preserved trailing inline code to validate, got %v", err)
|
||||
}
|
||||
changedTrailingCode := strings.Replace(translated, "`openclaw doctor`", "`openclaw fix`", 1)
|
||||
err := validateDocChunkTranslation(source, changedTrailingCode)
|
||||
if err == nil || !strings.Contains(err.Error(), "inline code mismatch") {
|
||||
t.Fatalf("expected changed inline code after the fence to be rejected, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDocChunkTranslationRejectsChangedCodeInSplitComponentBody(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -2312,6 +2460,7 @@ func TestTranslateDocBodyChunkedMasksInlineCodeAndListMarkers(t *testing.T) {
|
||||
body := strings.Join([]string{
|
||||
"- Visible prose uses `openclaw config`.",
|
||||
" 1. Visible prose keeps ``nested `ticks` `` exact.",
|
||||
"- Visible prose keeps Hailuo 2.3/02 exact.",
|
||||
"- Channel configs:",
|
||||
" - Telegram: Visible prose.",
|
||||
" - WhatsApp: Visible prose.",
|
||||
@@ -2336,7 +2485,7 @@ func TestTranslateDocBodyChunkedMasksInlineCodeAndListMarkers(t *testing.T) {
|
||||
t.Fatal("expected raw translator inputs")
|
||||
}
|
||||
for _, input := range translator.rawInputs {
|
||||
if strings.Contains(input, "`openclaw config`") || strings.Contains(input, "``nested `ticks` ``") {
|
||||
if strings.Contains(input, "`openclaw config`") || strings.Contains(input, "``nested `ticks` ``") || strings.Contains(input, "2.3/02") {
|
||||
t.Fatalf("expected inline code outside fences to be masked:\n%s", input)
|
||||
}
|
||||
if strings.Contains(input, "- Visible prose uses") || strings.Contains(input, "1. Visible prose keeps") || strings.Contains(input, "> - Visible prose inside a quote.") {
|
||||
@@ -2349,6 +2498,7 @@ func TestTranslateDocBodyChunkedMasksInlineCodeAndListMarkers(t *testing.T) {
|
||||
for _, exact := range []string{
|
||||
"- Видимый текст uses `openclaw config`.",
|
||||
" 1. Видимый текст keeps ``nested `ticks` `` exact.",
|
||||
"- Видимый текст keeps Hailuo 2.3/02 exact.",
|
||||
"- Channel configs:\n - Telegram: Видимый текст.\n - WhatsApp: Видимый текст.",
|
||||
"> - Видимый текст inside a quote.",
|
||||
"```md\n- Видимый текст and `fenced example` stay exposed.\n```",
|
||||
@@ -2363,6 +2513,27 @@ func TestTranslateDocBodyChunkedMasksInlineCodeAndListMarkers(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranslateDocBodyChunkedEscapesModelInventedListMarker(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body := "1. First step.\n2. Second step.\n\nSeptember begins the standard rate.\n"
|
||||
translated, err := translateDocBodyChunked(
|
||||
context.Background(), accidentalListMarkerTranslator{}, "concepts/model-failover.md", body, "en", "de",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("translateDocBodyChunked returned error: %v", err)
|
||||
}
|
||||
if !strings.Contains(translated, "1. First step.\n2. Second step.") {
|
||||
t.Fatalf("expected source list markers to be restored:\n%s", translated)
|
||||
}
|
||||
if !strings.Contains(translated, `1\. September beginnt der Standardtarif.`) {
|
||||
t.Fatalf("expected model-invented list marker to be escaped:\n%s", translated)
|
||||
}
|
||||
if err := validateDocBodyFencedLiterals(body, translated); err != nil {
|
||||
t.Fatalf("expected repaired final structure to validate: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranslateDocBodyChunkedRetriesSingletonFenceAfterValidationFailure(t *testing.T) {
|
||||
body := strings.Join([]string{
|
||||
"```md",
|
||||
@@ -2582,8 +2753,8 @@ func TestValidateDocBodyRejectsChangedCompositeLiteral(t *testing.T) {
|
||||
func TestExtractNumericValuesKeepsLowAmbiguityComposites(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := strings.Join(extractNumericValues("0xFF 0b101 0o755 1.5:1 24/7 1e-3 v1.2.3 v24/7 24/7z"), ",")
|
||||
if want := "0xFF,0b101,0o755,1.5:1,24/7,1e-3"; got != want {
|
||||
got := strings.Join(extractNumericValues("0xFF 0b101 0o755 1.5:1 24/7 1e-3 v1.2.3 v24/7 24/7z Hailuo-2.3/02"), ",")
|
||||
if want := "0xFF,0b101,0o755,1.5:1,24/7,1e-3,2.3/02"; got != want {
|
||||
t.Fatalf("unexpected composite literals: got=%q want=%q", got, want)
|
||||
}
|
||||
if err := validateDocChunkTranslation("Supports 1:1 conversations.\n", "Unterstützt 1:1-Unterhaltungen.\n"); err != nil {
|
||||
@@ -2592,6 +2763,9 @@ func TestExtractNumericValuesKeepsLowAmbiguityComposites(t *testing.T) {
|
||||
if err := validateDocChunkTranslation("Available 24/7.\n", "24/7 उपलब्ध।\n"); err != nil {
|
||||
t.Fatalf("expected translated prose around exact slash ratio to pass: %v", err)
|
||||
}
|
||||
if err := validateDocChunkTranslation("Hailuo 2.3/02 models.\n", "Hailuo-2.3/02-Modelle.\n"); err != nil {
|
||||
t.Fatalf("expected locale hyphen compound around exact ratio to pass: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractNumericValuesKeepsClockCoreBeforeMeridiemSuffix(t *testing.T) {
|
||||
|
||||
@@ -5,7 +5,7 @@ go 1.25.0
|
||||
toolchain go1.25.12
|
||||
|
||||
require (
|
||||
github.com/yuin/goldmark v1.8.2
|
||||
golang.org/x/net v0.55.0
|
||||
github.com/yuin/goldmark v1.8.5
|
||||
golang.org/x/net v0.57.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
|
||||
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||
github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA=
|
||||
github.com/yuin/goldmark v1.8.5/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
||||
@@ -182,6 +182,11 @@ func markdownListParentItemPath(list *ast.List) string {
|
||||
}
|
||||
|
||||
func extractMarkdownInlineCodeValues(body string) []string {
|
||||
if _, opening, _, _, _, ok := splitPureFencedDocSection(body); ok {
|
||||
if _, validOpening := parseMarkdownLiteralFenceOpening(opening); validOpening {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
parseSource := []byte(normalizeDocComponentsForMarkdownParse(body))
|
||||
fencedRanges := markdownClosedLiteralFenceByteRanges(string(parseSource))
|
||||
doc := goldmark.New(goldmark.WithExtensions(extension.GFM)).Parser().Parse(text.NewReader(parseSource))
|
||||
@@ -203,6 +208,29 @@ func extractMarkdownInlineCodeValues(body string) []string {
|
||||
return values
|
||||
}
|
||||
|
||||
func unwrapUnexpectedInlineCodeSpans(source, translated string) string {
|
||||
if len(extractMarkdownInlineCodeValues(source)) != 0 || len(extractMarkdownInlineCodeValues(translated)) == 0 {
|
||||
return translated
|
||||
}
|
||||
fenced := append(markdownFencedCodeRanges(translated), markdownClosedLiteralFenceByteRanges(translated)...)
|
||||
ranges := markdownBlockBacktickRanges(translated)
|
||||
for index := len(ranges) - 1; index >= 0; index-- {
|
||||
span := ranges[index]
|
||||
if rangeOverlapsAny(span, fenced) {
|
||||
continue
|
||||
}
|
||||
runLength := 0
|
||||
for span[0]+runLength < span[1] && translated[span[0]+runLength] == '`' {
|
||||
runLength++
|
||||
}
|
||||
if runLength == 0 || span[1]-runLength < span[0]+runLength {
|
||||
continue
|
||||
}
|
||||
translated = translated[:span[0]] + translated[span[0]+runLength:span[1]-runLength] + translated[span[1]:]
|
||||
}
|
||||
return translated
|
||||
}
|
||||
|
||||
func extractMarkdownFencedLiteralValues(body string) ([]string, []string, []string) {
|
||||
placeholders := []string{}
|
||||
directiveTokens := []string{}
|
||||
|
||||
@@ -14,7 +14,7 @@ var (
|
||||
linkLabelRe = regexp.MustCompile(`!?\[([^\]\r\n]+)\]\(([^)\r\n]+)\)`)
|
||||
placeholderRe = regexp.MustCompile(`__OC_I18N_\d+__`)
|
||||
listMarkerRe = regexp.MustCompile(`^([ \t]*(?:>[ \t]*)*)([-+*]|[0-9]+[.)])([ \t]+)`)
|
||||
listContainerPrefixRe = regexp.MustCompile(`^[ \t]*(?:>[ \t]*)*$`)
|
||||
listContainerPrefixRe = regexp.MustCompile(`^[ \t]*(?:(?:>[ \t]*)|(?:(?:[-+*]|[0-9]+[.)])[ \t]+))*$`)
|
||||
// Hard validation stays limited to low-ambiguity composite literals. Plain numbers remain
|
||||
// model-visible so target-language plurals and ordinals can change grammar without false failures.
|
||||
numericValueRe = regexp.MustCompile(`(?:0[xX][0-9A-Za-z_]+|0[bB][0-9A-Za-z_]+|0[oO][0-9A-Za-z_]+|[0-9]+(?:\.[0-9]+)?(?::[0-9]+(?:\.[0-9]+)?)+|[0-9]+(?:\.[0-9]+)?(?:/[0-9]+(?:\.[0-9]+)?)+|(?:[0-9]+(?:\.[0-9]+)?|\.[0-9]+)[eE][+-]?[0-9]+)`)
|
||||
@@ -87,8 +87,8 @@ func maskMarkdownDocSyntax(text string, nextPlaceholder func() string, placehold
|
||||
}
|
||||
inlineRanges = append(inlineRanges, protectedMarkdownLinkRanges(text)...)
|
||||
masked := maskByteRanges(text, inlineRanges, nextPlaceholder, placeholders, mapping)
|
||||
|
||||
return maskByteRanges(masked, markdownListMarkerRanges(masked), nextPlaceholder, placeholders, mapping)
|
||||
masked = maskByteRanges(masked, markdownListMarkerRanges(masked), nextPlaceholder, placeholders, mapping)
|
||||
return maskByteRanges(masked, compositeNumericValueRanges(masked), nextPlaceholder, placeholders, mapping)
|
||||
}
|
||||
|
||||
func markdownListMarkerRanges(text string) [][2]int {
|
||||
@@ -150,6 +150,131 @@ func normalizeMaskedListMarkerPlaceholders(text string, mapping map[string]strin
|
||||
return strings.Join(lines, "")
|
||||
}
|
||||
|
||||
func maskedListMarkerPlaceholders(mapping map[string]string) map[string]string {
|
||||
placeholders := make(map[string]string)
|
||||
for placeholder, original := range mapping {
|
||||
markerSpan := listMarkerRe.FindStringIndex(original)
|
||||
if markerSpan != nil && markerSpan[0] == 0 && markerSpan[1] == len(original) {
|
||||
placeholders[placeholder] = original
|
||||
}
|
||||
}
|
||||
return placeholders
|
||||
}
|
||||
|
||||
func normalizeMaskedListMarkerSpacing(source, translated string, listPlaceholders map[string]string) string {
|
||||
type replacement struct {
|
||||
start int
|
||||
end int
|
||||
value string
|
||||
}
|
||||
replacements := make([]replacement, 0, len(listPlaceholders))
|
||||
for placeholder := range listPlaceholders {
|
||||
sourcePosition := strings.Index(source, placeholder)
|
||||
translatedPosition := strings.Index(translated, placeholder)
|
||||
if sourcePosition < 0 || translatedPosition < 0 {
|
||||
continue
|
||||
}
|
||||
sourceStart := markdownWhitespaceRunStart(source, sourcePosition)
|
||||
translatedStart := markdownWhitespaceRunStart(translated, translatedPosition)
|
||||
sourceSpacing := source[sourceStart:sourcePosition]
|
||||
if translated[translatedStart:translatedPosition] == sourceSpacing {
|
||||
continue
|
||||
}
|
||||
replacements = append(replacements, replacement{
|
||||
start: translatedStart,
|
||||
end: translatedPosition,
|
||||
value: sourceSpacing,
|
||||
})
|
||||
}
|
||||
sort.Slice(replacements, func(i, j int) bool { return replacements[i].start > replacements[j].start })
|
||||
for _, item := range replacements {
|
||||
translated = translated[:item.start] + item.value + translated[item.end:]
|
||||
}
|
||||
return translated
|
||||
}
|
||||
|
||||
func markdownWhitespaceRunStart(text string, position int) int {
|
||||
for position > 0 {
|
||||
switch text[position-1] {
|
||||
case ' ', '\t', '\r', '\n':
|
||||
position--
|
||||
default:
|
||||
return position
|
||||
}
|
||||
}
|
||||
return position
|
||||
}
|
||||
|
||||
func escapeUnexpectedListItemBodyMarkers(source, translated string, listPlaceholders map[string]string) string {
|
||||
type insertion struct {
|
||||
position int
|
||||
}
|
||||
insertions := make([]insertion, 0)
|
||||
for placeholder := range listPlaceholders {
|
||||
sourcePosition := strings.Index(source, placeholder)
|
||||
translatedPosition := strings.Index(translated, placeholder)
|
||||
if sourcePosition < 0 || translatedPosition < 0 {
|
||||
continue
|
||||
}
|
||||
sourceBody := source[sourcePosition+len(placeholder):]
|
||||
translatedBody := translated[translatedPosition+len(placeholder):]
|
||||
sourceMatch := listMarkerRe.FindStringSubmatchIndex(sourceBody)
|
||||
translatedMatch := listMarkerRe.FindStringSubmatchIndex(translatedBody)
|
||||
if len(translatedMatch) < 6 || len(sourceMatch) >= 6 {
|
||||
continue
|
||||
}
|
||||
markerStart, markerEnd := translatedMatch[4], translatedMatch[5]
|
||||
insertAt := markerStart
|
||||
if markerEnd-markerStart > 1 {
|
||||
insertAt = markerEnd - 1
|
||||
}
|
||||
insertions = append(insertions, insertion{position: translatedPosition + len(placeholder) + insertAt})
|
||||
}
|
||||
sort.Slice(insertions, func(i, j int) bool { return insertions[i].position > insertions[j].position })
|
||||
for _, item := range insertions {
|
||||
translated = translated[:item.position] + `\` + translated[item.position:]
|
||||
}
|
||||
return translated
|
||||
}
|
||||
|
||||
func escapeUnexpectedMarkdownListMarkers(text string, listPlaceholders map[string]string) string {
|
||||
ranges := markdownListMarkerRanges(text)
|
||||
if len(ranges) == 0 {
|
||||
return text
|
||||
}
|
||||
var out strings.Builder
|
||||
position := 0
|
||||
for _, span := range ranges {
|
||||
lineEnd := strings.IndexByte(text[span[1]:], '\n')
|
||||
if lineEnd < 0 {
|
||||
lineEnd = len(text)
|
||||
} else {
|
||||
lineEnd += span[1]
|
||||
}
|
||||
if placeholder := placeholderRe.FindString(text[span[1]:lineEnd]); placeholder != "" {
|
||||
if _, ok := listPlaceholders[placeholder]; ok && strings.HasPrefix(text[span[1]:lineEnd], placeholder) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
value := text[span[0]:span[1]]
|
||||
match := listMarkerRe.FindStringSubmatchIndex(value)
|
||||
if len(match) < 6 {
|
||||
continue
|
||||
}
|
||||
markerStart, markerEnd := match[4], match[5]
|
||||
insertAt := markerStart
|
||||
if markerEnd-markerStart > 1 {
|
||||
insertAt = markerEnd - 1
|
||||
}
|
||||
absolute := span[0] + insertAt
|
||||
out.WriteString(text[position:absolute])
|
||||
out.WriteByte('\\')
|
||||
position = absolute
|
||||
}
|
||||
out.WriteString(text[position:])
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func protectedMarkdownLinkRanges(text string) [][2]int {
|
||||
ranges := make([][2]int, 0)
|
||||
for _, match := range linkLabelRe.FindAllStringSubmatchIndex(text, -1) {
|
||||
@@ -181,11 +306,20 @@ func markdownInlineLinkDestination(value string) string {
|
||||
}
|
||||
|
||||
func extractNumericValues(text string) []string {
|
||||
ranges := compositeNumericValueRanges(text)
|
||||
values := make([]string, 0, len(ranges))
|
||||
for _, span := range ranges {
|
||||
values = append(values, text[span[0]:span[1]])
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func compositeNumericValueRanges(text string) [][2]int {
|
||||
protocolRanges := make([][2]int, 0)
|
||||
for _, span := range placeholderRe.FindAllStringIndex(text, -1) {
|
||||
protocolRanges = append(protocolRanges, [2]int{span[0], span[1]})
|
||||
}
|
||||
values := make([]string, 0)
|
||||
ranges := make([][2]int, 0)
|
||||
for _, span := range numericValueRe.FindAllStringIndex(text, -1) {
|
||||
candidate := [2]int{span[0], span[1]}
|
||||
if hasCompositeNumericLeadingContinuation(text, candidate[0]) ||
|
||||
@@ -193,9 +327,9 @@ func extractNumericValues(text string) []string {
|
||||
rangeOverlapsAny(candidate, protocolRanges) {
|
||||
continue
|
||||
}
|
||||
values = append(values, text[span[0]:span[1]])
|
||||
ranges = append(ranges, [2]int{span[0], span[1]})
|
||||
}
|
||||
return values
|
||||
return ranges
|
||||
}
|
||||
|
||||
func hasClockMeridiemSuffix(text string, span [2]int) bool {
|
||||
@@ -220,7 +354,7 @@ func hasCompositeNumericLeadingContinuation(text string, position int) bool {
|
||||
}
|
||||
return position > 0 && isCompositeNumericWordByte(text[position-1])
|
||||
}
|
||||
return value == '.' || value == '-' || isCompositeNumericWordByte(value)
|
||||
return value == '.' || isCompositeNumericWordByte(value)
|
||||
}
|
||||
|
||||
func hasCompositeNumericContinuation(text string, position int) bool {
|
||||
|
||||
@@ -52,6 +52,14 @@ export function resolveRoute(
|
||||
|
||||
export function sanitizeDocsConfigForEnglishOnly(value: unknown): unknown;
|
||||
|
||||
export function prepareExternalLinkAuditTree(
|
||||
repoRoot: string,
|
||||
outputDir: string,
|
||||
): {
|
||||
files: number;
|
||||
projectedLinks: number;
|
||||
};
|
||||
|
||||
export function prepareMirroredDocsDir(
|
||||
sourceDir?: string,
|
||||
options?: {
|
||||
|
||||
@@ -6,12 +6,19 @@ import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { createProcessor } from "@mdx-js/mdx";
|
||||
import MarkdownIt from "markdown-it";
|
||||
import { resolveClawHubRepoPath, syncClawHubDocsTree } from "./docs-sync-publish.mjs";
|
||||
import { resolveNpmRunner } from "./npm-runner.mjs";
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const DOCS_DIR = path.join(ROOT, "docs");
|
||||
const DOCS_JSON_PATH = path.join(DOCS_DIR, "docs.json");
|
||||
const ROOT_MARKDOWN_FILES = ["README.md", "CONTRIBUTING.md", "SECURITY.md"];
|
||||
const MDX_PROCESSOR = createProcessor({ format: "mdx" });
|
||||
const MARKDOWN_PARSER = new MarkdownIt({ html: false });
|
||||
const HTML_MARKDOWN_PARSER = new MarkdownIt({ html: true });
|
||||
const VERBATIM_MDX_ELEMENTS = new Set(["code", "pre", "script", "style", "textarea"]);
|
||||
const MINTLIFY_CLI_VERSION = "4.2.715";
|
||||
const MINTLIFY_BROKEN_LINKS_ARGS = [
|
||||
"exec",
|
||||
@@ -53,6 +60,210 @@ function walk(dir) {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** @param {string} value */
|
||||
function escapeHtmlAttribute(value) {
|
||||
return value
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">");
|
||||
}
|
||||
|
||||
/**
|
||||
* Projects parsed Markdown links onto their source lines. MDX parsing owns the
|
||||
* normal path; markdown-it is a tolerant fallback for legacy malformed pages.
|
||||
*
|
||||
* @param {string} raw
|
||||
*/
|
||||
function projectExternalLinkMarkdown(raw) {
|
||||
// Lychee also receives every original source file for HTML attributes and bare URLs.
|
||||
// This line-stable companion contains only parsed Markdown links hidden by MDX blocks.
|
||||
const projected = raw.split("\n").map((line) => (line.endsWith("\r") ? "\r" : ""));
|
||||
let projectedLinks = 0;
|
||||
const appendLink = (line, url) => {
|
||||
if (!Number.isInteger(line) || line < 1 || line > projected.length || !url) {
|
||||
return;
|
||||
}
|
||||
const index = line - 1;
|
||||
const suffix = projected[index].endsWith("\r") ? "\r" : "";
|
||||
const existing = suffix ? projected[index].slice(0, -1) : projected[index];
|
||||
const separator = existing ? " " : "";
|
||||
projected[index] =
|
||||
`${existing}${separator}<a href="${escapeHtmlAttribute(url)}">link</a>${suffix}`;
|
||||
projectedLinks += 1;
|
||||
};
|
||||
|
||||
try {
|
||||
const tree = MDX_PROCESSOR.parse(raw);
|
||||
const definitions = new Map();
|
||||
const collectDefinitions = (node) => {
|
||||
if (
|
||||
node.type === "definition" &&
|
||||
node.identifier &&
|
||||
node.url &&
|
||||
!definitions.has(node.identifier)
|
||||
) {
|
||||
definitions.set(node.identifier, node.url);
|
||||
}
|
||||
for (const child of node.children ?? []) {
|
||||
collectDefinitions(child);
|
||||
}
|
||||
};
|
||||
collectDefinitions(tree);
|
||||
|
||||
const collectLinks = (node, verbatim = false) => {
|
||||
const nextVerbatim =
|
||||
verbatim ||
|
||||
((node.type === "mdxJsxFlowElement" || node.type === "mdxJsxTextElement") &&
|
||||
VERBATIM_MDX_ELEMENTS.has(node.name));
|
||||
if (!nextVerbatim) {
|
||||
if ((node.type === "link" || node.type === "image") && node.url) {
|
||||
appendLink(node.position?.start.line, node.url);
|
||||
} else if (
|
||||
(node.type === "linkReference" || node.type === "imageReference") &&
|
||||
node.identifier
|
||||
) {
|
||||
appendLink(node.position?.start.line, definitions.get(node.identifier));
|
||||
}
|
||||
}
|
||||
for (const child of node.children ?? []) {
|
||||
collectLinks(child, nextVerbatim);
|
||||
}
|
||||
};
|
||||
collectLinks(tree);
|
||||
} catch {
|
||||
const rawLines = raw.split("\n");
|
||||
const inlineVerbatimLinks = new Map();
|
||||
const transparentEnv = {};
|
||||
const transparentTokens = MARKDOWN_PARSER.parse(raw, transparentEnv);
|
||||
const fallbackCodeLines = new Set();
|
||||
for (const token of transparentTokens) {
|
||||
if ((token.type === "fence" || token.type === "code_block") && token.map) {
|
||||
for (let line = token.map[0]; line < token.map[1]; line += 1) {
|
||||
fallbackCodeLines.add(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
const childUrl = (child) =>
|
||||
child.type === "link_open"
|
||||
? child.attrGet("href")
|
||||
: child.type === "image"
|
||||
? child.attrGet("src")
|
||||
: undefined;
|
||||
const sourceLineForUrl = (token, url) => {
|
||||
const escapedUrl = url.replaceAll("&", "&");
|
||||
for (let line = token.map[0]; line < token.map[1]; line += 1) {
|
||||
if (rawLines[line]?.includes(url) || rawLines[line]?.includes(escapedUrl)) {
|
||||
return line;
|
||||
}
|
||||
for (const inlineToken of MARKDOWN_PARSER.parseInline(
|
||||
rawLines[line] ?? "",
|
||||
transparentEnv,
|
||||
)) {
|
||||
if ((inlineToken.children ?? []).some((child) => childUrl(child) === url)) {
|
||||
return line;
|
||||
}
|
||||
}
|
||||
}
|
||||
return token.map[0];
|
||||
};
|
||||
const inlineVerbatimStack = [];
|
||||
for (const [line, rawLine] of rawLines.entries()) {
|
||||
if (inlineVerbatimStack.length === 0 && fallbackCodeLines.has(line)) {
|
||||
continue;
|
||||
}
|
||||
for (const token of HTML_MARKDOWN_PARSER.parseInline(rawLine, transparentEnv)) {
|
||||
for (const child of token.children ?? []) {
|
||||
if (child.type === "html_inline") {
|
||||
const closingTag = child.content.match(/^<\/([a-z][A-Za-z0-9.:_-]*)[\t ]*>$/u)?.[1];
|
||||
if (closingTag) {
|
||||
const openingIndex = inlineVerbatimStack.lastIndexOf(closingTag);
|
||||
if (openingIndex >= 0) {
|
||||
inlineVerbatimStack.length = openingIndex;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const openingTag = child.content.match(
|
||||
/^<([a-z][A-Za-z0-9.:_-]*)(?:[\t ][^<>]*?)?(\/?)>$/u,
|
||||
);
|
||||
if (openingTag && !openingTag[2] && VERBATIM_MDX_ELEMENTS.has(openingTag[1])) {
|
||||
inlineVerbatimStack.push(openingTag[1]);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const url = childUrl(child);
|
||||
if (inlineVerbatimStack.length > 0 && url) {
|
||||
const key = `${line}\0${url}`;
|
||||
inlineVerbatimLinks.set(key, (inlineVerbatimLinks.get(key) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const token of transparentTokens) {
|
||||
if (token.type !== "inline" || !token.map) {
|
||||
continue;
|
||||
}
|
||||
for (const child of token.children ?? []) {
|
||||
const url = childUrl(child);
|
||||
if (!url) {
|
||||
continue;
|
||||
}
|
||||
const sourceLine = sourceLineForUrl(token, url);
|
||||
const key = `${sourceLine}\0${url}`;
|
||||
const hiddenOccurrences = inlineVerbatimLinks.get(key) ?? 0;
|
||||
if (hiddenOccurrences > 0) {
|
||||
inlineVerbatimLinks.set(key, hiddenOccurrences - 1);
|
||||
continue;
|
||||
}
|
||||
appendLink(sourceLine + 1, url);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { text: projected.join("\n"), projectedLinks };
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a parallel docs tree that exposes Markdown nested in HTML/MDX blocks.
|
||||
* Original inputs still cover tag attributes; projected inputs cover children.
|
||||
*
|
||||
* @param {string} repoRoot
|
||||
* @param {string} outputDir
|
||||
*/
|
||||
export function prepareExternalLinkAuditTree(repoRoot, outputDir) {
|
||||
const root = path.resolve(repoRoot);
|
||||
const docsRoot = path.join(root, "docs");
|
||||
const outputRoot = path.resolve(outputDir);
|
||||
if (fs.existsSync(outputRoot)) {
|
||||
throw new Error(`external-link audit input already exists: ${outputRoot}`);
|
||||
}
|
||||
const outputFromDocs = path.relative(docsRoot, outputRoot);
|
||||
if (
|
||||
outputFromDocs === "" ||
|
||||
(!outputFromDocs.startsWith(`..${path.sep}`) &&
|
||||
outputFromDocs !== ".." &&
|
||||
!path.isAbsolute(outputFromDocs))
|
||||
) {
|
||||
throw new Error("external-link audit output must be outside docs");
|
||||
}
|
||||
|
||||
const sourcePaths = [
|
||||
...walk(docsRoot).filter((filePath) => /\.mdx?$/iu.test(filePath)),
|
||||
...ROOT_MARKDOWN_FILES.map((filename) => path.join(root, filename)),
|
||||
];
|
||||
let projectedLinks = 0;
|
||||
for (const sourcePath of sourcePaths) {
|
||||
const targetPath = path.join(outputRoot, path.relative(root, sourcePath));
|
||||
const raw = fs.readFileSync(sourcePath, "utf8");
|
||||
const projected = projectExternalLinkMarkdown(raw);
|
||||
projectedLinks += projected.projectedLinks;
|
||||
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
||||
fs.writeFileSync(targetPath, projected.text, "utf8");
|
||||
}
|
||||
|
||||
return { files: sourcePaths.length, projectedLinks };
|
||||
}
|
||||
|
||||
/** @param {string} p */
|
||||
function normalizeSlashes(p) {
|
||||
return p.replace(/\\/g, "/");
|
||||
@@ -588,6 +799,17 @@ export function auditDocsLinks(options = {}) {
|
||||
*/
|
||||
export function runDocsLinkAuditCli(options = {}) {
|
||||
const args = options.args ?? process.argv.slice(2);
|
||||
if (args[0] === "--prepare-external-links") {
|
||||
if (args.length !== 2 || !args[1]) {
|
||||
console.error("usage: docs-link-audit.mjs --prepare-external-links <output-dir>");
|
||||
return 1;
|
||||
}
|
||||
const result = prepareExternalLinkAuditTree(ROOT, path.resolve(ROOT, args[1]));
|
||||
console.log(`prepared_external_link_files=${result.files}`);
|
||||
console.log(`projected_markdown_links=${result.projectedLinks}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (args.includes("--anchors")) {
|
||||
const spawnSyncImpl = options.spawnSyncImpl ?? spawnSync;
|
||||
const prepareAnchorAuditDocsDirImpl =
|
||||
|
||||
@@ -13,6 +13,13 @@ export function parseArgs(argv: unknown): {
|
||||
export function resolveClawHubRepoPath(value?: string, options?: Record<string, unknown>): string;
|
||||
/** Reports locale pages whose canonical source page no longer exists without deleting them. */
|
||||
export function reportOrphanLocaleDocs(targetDocsDir: string): number;
|
||||
/** Applies translated tab and group labels without replacing canonical page routes. */
|
||||
export function applyLocaleNavLabelOverlay(
|
||||
fullNav: Record<string, unknown>,
|
||||
labelOverlay: Record<string, unknown>,
|
||||
): Record<string, unknown>;
|
||||
/** Composes the publish docs configuration with generated locale navigation. */
|
||||
export function composeDocsConfig(): Record<string, unknown>;
|
||||
/**
|
||||
* Mirrors ClawHub docs into the target docs tree.
|
||||
*/
|
||||
|
||||
@@ -400,14 +400,101 @@ function cloneEnglishLanguageNav(englishNav, locale) {
|
||||
};
|
||||
}
|
||||
|
||||
function composeLocaleNav(locale, englishNav) {
|
||||
if (locale.navMode === "clone-en") {
|
||||
return cloneEnglishLanguageNav(englishNav, locale);
|
||||
function collectNavPages(entry, pages = new Set()) {
|
||||
if (typeof entry === "string") {
|
||||
pages.add(entry);
|
||||
return pages;
|
||||
}
|
||||
return readJson(path.join(SOURCE_DOCS_DIR, ".i18n", locale.navFile));
|
||||
if (Array.isArray(entry)) {
|
||||
for (const item of entry) {
|
||||
collectNavPages(item, pages);
|
||||
}
|
||||
return pages;
|
||||
}
|
||||
if (!entry || typeof entry !== "object") {
|
||||
return pages;
|
||||
}
|
||||
if (typeof entry.page === "string") {
|
||||
pages.add(entry.page);
|
||||
}
|
||||
collectNavPages(entry.pages, pages);
|
||||
collectNavPages(entry.groups, pages);
|
||||
collectNavPages(entry.tabs, pages);
|
||||
return pages;
|
||||
}
|
||||
|
||||
function composeDocsConfig() {
|
||||
function findBestNavMatchIndex(candidates, overlayEntry, excludedIndexes = new Set()) {
|
||||
const overlayPages = collectNavPages(overlayEntry);
|
||||
let bestIndex = -1;
|
||||
let bestScore = 0;
|
||||
for (const [index, candidate] of candidates.entries()) {
|
||||
if (excludedIndexes.has(index)) {
|
||||
continue;
|
||||
}
|
||||
const candidatePages = collectNavPages(candidate);
|
||||
let score = 0;
|
||||
for (const page of overlayPages) {
|
||||
if (candidatePages.has(page)) {
|
||||
score += 1;
|
||||
}
|
||||
}
|
||||
if (score > bestScore) {
|
||||
bestIndex = index;
|
||||
bestScore = score;
|
||||
}
|
||||
}
|
||||
return bestIndex;
|
||||
}
|
||||
|
||||
export function applyLocaleNavLabelOverlay(fullNav, labelOverlay) {
|
||||
const tabs = Array.isArray(fullNav.tabs)
|
||||
? fullNav.tabs.map((tab) => ({
|
||||
...tab,
|
||||
groups: Array.isArray(tab.groups) ? tab.groups.map((group) => ({ ...group })) : tab.groups,
|
||||
}))
|
||||
: fullNav.tabs;
|
||||
const composed = { ...fullNav, tabs };
|
||||
if (!Array.isArray(tabs) || !Array.isArray(labelOverlay?.tabs)) {
|
||||
return composed;
|
||||
}
|
||||
|
||||
for (const overlayTab of labelOverlay.tabs) {
|
||||
const tabIndex = findBestNavMatchIndex(tabs, overlayTab);
|
||||
if (tabIndex < 0) {
|
||||
continue;
|
||||
}
|
||||
const tab = tabs[tabIndex];
|
||||
if (typeof overlayTab.tab === "string") {
|
||||
tab.tab = overlayTab.tab;
|
||||
}
|
||||
if (!Array.isArray(tab.groups) || !Array.isArray(overlayTab.groups)) {
|
||||
continue;
|
||||
}
|
||||
const matchedGroupIndexes = new Set();
|
||||
for (const overlayGroup of overlayTab.groups) {
|
||||
const groupIndex = findBestNavMatchIndex(tab.groups, overlayGroup, matchedGroupIndexes);
|
||||
if (groupIndex >= 0 && typeof overlayGroup.group === "string") {
|
||||
tab.groups[groupIndex].group = overlayGroup.group;
|
||||
matchedGroupIndexes.add(groupIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
return composed;
|
||||
}
|
||||
|
||||
function composeLocaleNav(locale, englishNav) {
|
||||
const cloned = cloneEnglishLanguageNav(englishNav, locale);
|
||||
if (!locale.navFile) {
|
||||
return cloned;
|
||||
}
|
||||
const overlayPath = path.join(SOURCE_DOCS_DIR, ".i18n", locale.navFile);
|
||||
if (!fs.existsSync(overlayPath)) {
|
||||
return cloned;
|
||||
}
|
||||
return applyLocaleNavLabelOverlay(cloned, readJson(overlayPath));
|
||||
}
|
||||
|
||||
export function composeDocsConfig() {
|
||||
const sourceConfig = readJson(SOURCE_CONFIG_PATH);
|
||||
const languages = sourceConfig?.navigation?.languages;
|
||||
|
||||
|
||||
+10
-14
@@ -4,7 +4,7 @@
|
||||
# `bare` is a clean Node/Git runner for install/update lanes. `functional`
|
||||
# installs the prepared OpenClaw npm tarball into /app for built-app lanes.
|
||||
|
||||
FROM node:24-bookworm-slim@sha256:242549cd46785b480c832479a730f4f2a20865d61ea2e404fdb2a5c3d3b73ecf AS e2e-runner
|
||||
FROM node:24-bookworm-slim@sha256:6f7b03f7c2c8e2e784dcf9295400527b9b1270fd37b7e9a7285cf83b6951452d AS e2e-runner
|
||||
|
||||
# openssl provisions short-lived fixture certificates for HTTPS-only provider
|
||||
# routes. python3 covers package/plugin install paths that execute helper scripts.
|
||||
@@ -45,15 +45,14 @@ FROM bare AS functional-manifest
|
||||
# manifest alone so the expensive install layer below is keyed on it and stays
|
||||
# a warm-cache hit until dependencies actually change.
|
||||
COPY --from=openclaw_package --chown=appuser:appuser openclaw-current.tgz /tmp/openclaw-current.tgz
|
||||
# Bundled dependencies ship inside the tarball and are absent from the
|
||||
# shrinkwrap, and the packaged lifecycle scripts reference files outside this
|
||||
# manifest-only tree; drop both (plus dev deps, which the shrinkwrap omits) so
|
||||
# the install below reifies registry dependencies only.
|
||||
# Bundled dependencies ship inside the tarball, and the packaged lifecycle
|
||||
# scripts reference files outside this manifest-only tree. Drop both plus dev
|
||||
# dependencies so the install below reifies registry dependencies only.
|
||||
RUN <<'PREPARE_MANIFEST'
|
||||
set -eu
|
||||
mkdir -p /tmp/openclaw-deps
|
||||
tar -xzf /tmp/openclaw-current.tgz -C /tmp/openclaw-deps --strip-components=1 \
|
||||
package/package.json package/npm-shrinkwrap.json
|
||||
package/package.json
|
||||
node - <<'NODE'
|
||||
const fs = require("node:fs");
|
||||
const manifestPath = "/tmp/openclaw-deps/package.json";
|
||||
@@ -70,14 +69,11 @@ PREPARE_MANIFEST
|
||||
|
||||
FROM bare AS functional-deps
|
||||
|
||||
# Cache key: normalized manifest + shrinkwrap contents only (COPY hashes file
|
||||
# content, not the tarball), so unchanged dependencies skip the full reify.
|
||||
# Cache key: normalized manifest contents only, so unchanged dependencies skip
|
||||
# the full reify.
|
||||
COPY --from=functional-manifest --chown=appuser:appuser /tmp/openclaw-deps /tmp/openclaw-deps
|
||||
# `npm install` (not `npm ci`) because the generated shrinkwrap is not
|
||||
# guaranteed to pass `npm ci`'s strict manifest sync check; the shrinkwrap
|
||||
# still pins every synced edge, and this reify follows it more faithfully than
|
||||
# `npm install -g <tarball>` does. Drop npm's hidden tree manifest so the
|
||||
# copied node_modules matches a plain package install.
|
||||
# Drop npm's hidden tree manifest so the copied node_modules matches a plain
|
||||
# package install.
|
||||
# The pinned Node image owns uid 1000, so useradd assigns appuser uid/gid 1001.
|
||||
RUN --mount=type=cache,target=/home/appuser/.npm,uid=1001,gid=1001,sharing=locked \
|
||||
cd /tmp/openclaw-deps \
|
||||
@@ -99,7 +95,7 @@ COPY --from=functional-deps --chown=appuser:appuser /tmp/openclaw-deps/node_modu
|
||||
# cycle through the link.
|
||||
RUN tar -xzf /tmp/openclaw-current.tgz -C /app --strip-components=1 \
|
||||
&& chmod +x /app/openclaw.mjs \
|
||||
&& node /app/scripts/postinstall-bundled-plugins.mjs \
|
||||
&& node --input-type=module -e "import { runBundledPluginPostinstall } from '/app/scripts/postinstall-bundled-plugins.mjs'; runBundledPluginPostinstall();" \
|
||||
&& ln -sfn /app /app/node_modules/openclaw \
|
||||
&& mkdir -p "$HOME/.local/bin" \
|
||||
&& ln -sf /app/openclaw.mjs "$HOME/.local/bin/openclaw" \
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
FROM node:24-bookworm-slim@sha256:242549cd46785b480c832479a730f4f2a20865d61ea2e404fdb2a5c3d3b73ecf
|
||||
FROM node:24-bookworm-slim@sha256:6f7b03f7c2c8e2e784dcf9295400527b9b1270fd37b7e9a7285cf83b6951452d
|
||||
|
||||
RUN corepack enable
|
||||
|
||||
|
||||
@@ -1,324 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Definition:
|
||||
# Docker/package E2E proof for local channel plugin trust gating. The host
|
||||
# mode builds or reuses the functional Docker image, then runs the container
|
||||
# mode against the installed OpenClaw package.
|
||||
#
|
||||
# Parameters:
|
||||
# --container: run the in-container scenario. Host mode is the default.
|
||||
# OPENCLAW_CHANNEL_PLUGIN_TRUST_E2E_IMAGE: override the Docker image name.
|
||||
# OPENCLAW_CHANNEL_PLUGIN_TRUST_E2E_SKIP_BUILD=1: reuse/pull the image.
|
||||
#
|
||||
# Outputs:
|
||||
# stdout logs each case and prints "Channel plugin trust Docker E2E passed."
|
||||
# Exit 0 means both representative package-environment cases passed.
|
||||
# Exit non-zero means the package build, Docker run, or trust assertion failed.
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
bash scripts/e2e/channel-plugin-trust-docker.sh [--container]
|
||||
|
||||
Description:
|
||||
Proves the packaged OpenClaw CLI enforces local channel plugin trust for
|
||||
plugins.load.paths entries in a clean Docker/package environment.
|
||||
|
||||
Options:
|
||||
--container Run the in-container scenario. Used by the host wrapper.
|
||||
-h, --help Show this help.
|
||||
|
||||
Environment:
|
||||
OPENCLAW_CHANNEL_PLUGIN_TRUST_E2E_IMAGE Override Docker image name.
|
||||
OPENCLAW_CHANNEL_PLUGIN_TRUST_E2E_SKIP_BUILD Reuse/pull image instead of building.
|
||||
OPENCLAW_TEST_STATE_SCRIPT_B64 Required in --container mode.
|
||||
|
||||
Outputs:
|
||||
Prints case progress and PASS lines to stdout. Exits non-zero on assertion
|
||||
failure and leaves the failing command output in the container log.
|
||||
|
||||
Examples:
|
||||
bash scripts/e2e/channel-plugin-trust-docker.sh
|
||||
OPENCLAW_CHANNEL_PLUGIN_TRUST_E2E_SKIP_BUILD=1 bash scripts/e2e/channel-plugin-trust-docker.sh
|
||||
EOF
|
||||
}
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
|
||||
run_openclaw() {
|
||||
if command -v openclaw >/dev/null 2>&1; then
|
||||
openclaw "$@"
|
||||
return
|
||||
fi
|
||||
if [ -f /app/openclaw.mjs ]; then
|
||||
node /app/openclaw.mjs "$@"
|
||||
return
|
||||
fi
|
||||
echo "openclaw CLI not found in Docker image" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
write_load_paths_fixture() {
|
||||
local plugin_dir="${1:?missing plugin dir}"
|
||||
local origin="${2:?missing origin}"
|
||||
local plugin_id="e2e-load-paths-shadow"
|
||||
local channel_id="e2e-load-paths"
|
||||
mkdir -p "$plugin_dir"
|
||||
|
||||
cat >"$plugin_dir/package.json" <<EOF
|
||||
{
|
||||
"name": "@openclaw-e2e/$plugin_id",
|
||||
"version": "0.0.0-e2e",
|
||||
"private": true,
|
||||
"openclaw": {
|
||||
"extensions": ["./index.cjs"],
|
||||
"setupEntry": "./setup-entry.cjs",
|
||||
"channel": {
|
||||
"id": "$channel_id",
|
||||
"label": "E2E Load Paths",
|
||||
"selectionLabel": "E2E Load Paths",
|
||||
"docsPath": "/channels/$channel_id",
|
||||
"blurb": "Docker E2E local trust fixture."
|
||||
}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
cat >"$plugin_dir/openclaw.plugin.json" <<EOF
|
||||
{
|
||||
"id": "$plugin_id",
|
||||
"name": "E2E load-paths Shadow",
|
||||
"description": "Docker E2E local trust fixture.",
|
||||
"activation": { "onStartup": false },
|
||||
"channels": ["$channel_id"],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
cat >"$plugin_dir/index.cjs" <<EOF
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const importMarker = process.env.PLUGINTRUST_IMPORT_MARKER;
|
||||
const registerMarker = process.env.PLUGINTRUST_REGISTER_MARKER;
|
||||
const canary = process.env.PLUGINTRUST_CANARY ?? "<no-canary>";
|
||||
function writeMarker(target, payload) {
|
||||
if (!target) return;
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
fs.writeFileSync(target, payload, "utf8");
|
||||
}
|
||||
writeMarker(importMarker, "imported|origin=$origin|canary=" + canary + "\\n");
|
||||
module.exports = {
|
||||
id: "$plugin_id",
|
||||
register(api) {
|
||||
writeMarker(registerMarker, "registered|origin=$origin|canary=" + canary + "\\n");
|
||||
api.registerChannel({
|
||||
plugin: {
|
||||
id: "$channel_id",
|
||||
meta: {
|
||||
id: "$channel_id",
|
||||
label: "E2E Load Paths",
|
||||
selectionLabel: "E2E Load Paths",
|
||||
docsPath: "/channels/$channel_id",
|
||||
blurb: "Docker E2E local trust fixture.",
|
||||
},
|
||||
capabilities: { chatTypes: ["direct"] },
|
||||
config: {
|
||||
listAccountIds: () => [],
|
||||
resolveAccount: () => ({ accountId: "default" }),
|
||||
},
|
||||
outbound: { deliveryMode: "direct" },
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
EOF
|
||||
|
||||
cat >"$plugin_dir/setup-entry.cjs" <<EOF
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const importMarker = process.env.PLUGINTRUST_SETUP_IMPORT_MARKER;
|
||||
const registerMarker = process.env.PLUGINTRUST_SETUP_REGISTER_MARKER;
|
||||
const canary = process.env.PLUGINTRUST_CANARY ?? "<no-canary>";
|
||||
function writeMarker(target, payload) {
|
||||
if (!target) return;
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
fs.writeFileSync(target, payload, "utf8");
|
||||
}
|
||||
writeMarker(importMarker, "setup-imported|origin=$origin|canary=" + canary + "\\n");
|
||||
module.exports = {
|
||||
plugin: {
|
||||
id: "$channel_id",
|
||||
meta: {
|
||||
id: "$channel_id",
|
||||
label: "E2E Load Paths setup",
|
||||
selectionLabel: "E2E Load Paths setup",
|
||||
docsPath: "/channels/$channel_id",
|
||||
blurb: "Docker E2E local trust setup fixture.",
|
||||
},
|
||||
capabilities: { chatTypes: ["direct"] },
|
||||
config: {
|
||||
listAccountIds: () => [],
|
||||
resolveAccount: () => ({ accountId: "default" }),
|
||||
},
|
||||
outbound: { deliveryMode: "direct" },
|
||||
setup: {
|
||||
validateInput: ({ input }) => {
|
||||
writeMarker(
|
||||
registerMarker,
|
||||
"setup-registered|origin=$origin|canary=" + canary + "|token=" + (input?.token ?? "<no-token>") + "\\n",
|
||||
);
|
||||
return null;
|
||||
},
|
||||
applyAccountConfig: ({ cfg }) => cfg,
|
||||
},
|
||||
},
|
||||
};
|
||||
EOF
|
||||
}
|
||||
|
||||
write_case_config() {
|
||||
local plugin_dir="${1:?missing plugin dir}"
|
||||
local trusted="${2:?missing trusted flag}"
|
||||
local plugin_id="e2e-load-paths-shadow"
|
||||
mkdir -p "$(dirname "$OPENCLAW_CONFIG_PATH")"
|
||||
if [ "$trusted" = "1" ]; then
|
||||
cat >"$OPENCLAW_CONFIG_PATH" <<EOF
|
||||
{
|
||||
"plugins": {
|
||||
"enabled": true,
|
||||
"allow": ["$plugin_id"],
|
||||
"load": {
|
||||
"paths": ["$plugin_dir"]
|
||||
}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
else
|
||||
cat >"$OPENCLAW_CONFIG_PATH" <<EOF
|
||||
{
|
||||
"plugins": {
|
||||
"enabled": true,
|
||||
"load": {
|
||||
"paths": ["$plugin_dir"]
|
||||
}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
fi
|
||||
}
|
||||
|
||||
run_case() {
|
||||
local case_id="${1:?missing case id}"
|
||||
local trusted="${2:?missing trusted flag}"
|
||||
local scratch
|
||||
scratch="$(mktemp -d "/tmp/openclaw-channel-plugin-trust-$case_id.XXXXXX")"
|
||||
local plugin_dir="$scratch/e2e-load-paths-shadow"
|
||||
local marker_dir="$scratch/markers"
|
||||
local stdout_file="$scratch/stdout.log"
|
||||
local stderr_file="$scratch/stderr.log"
|
||||
local canary="$case_id-canary"
|
||||
mkdir -p "$marker_dir"
|
||||
|
||||
write_load_paths_fixture "$plugin_dir" "config"
|
||||
write_case_config "$plugin_dir" "$trusted"
|
||||
|
||||
echo "[CASE $case_id] plugins.load.paths trusted=$trusted"
|
||||
set +e
|
||||
PLUGINTRUST_IMPORT_MARKER="$marker_dir/import.marker" \
|
||||
PLUGINTRUST_REGISTER_MARKER="$marker_dir/register.marker" \
|
||||
PLUGINTRUST_SETUP_IMPORT_MARKER="$marker_dir/setup-import.marker" \
|
||||
PLUGINTRUST_SETUP_REGISTER_MARKER="$marker_dir/setup-register.marker" \
|
||||
PLUGINTRUST_CANARY="$canary" \
|
||||
run_openclaw channels add --channel e2e-load-paths --token "$canary" \
|
||||
>"$stdout_file" 2>"$stderr_file"
|
||||
local status=$?
|
||||
set -e
|
||||
|
||||
if [ "$trusted" = "1" ] && [ "$status" -ne 0 ]; then
|
||||
echo "Expected trusted case to succeed; exit=$status" >&2
|
||||
cat "$stderr_file" >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$trusted" = "1" ]; then
|
||||
for marker in setup-import setup-register; do
|
||||
local marker_path="$marker_dir/$marker.marker"
|
||||
if [ ! -f "$marker_path" ]; then
|
||||
echo "Expected $marker marker for trusted case" >&2
|
||||
cat "$stderr_file" >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -qF "canary=$canary" "$marker_path"; then
|
||||
echo "$marker marker did not include canary $canary" >&2
|
||||
cat "$marker_path" >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
echo "PASS: $case_id trusted load-paths setup entry executed"
|
||||
else
|
||||
for marker in setup-import setup-register import register; do
|
||||
if [ -e "$marker_dir/$marker.marker" ]; then
|
||||
echo "Expected $marker marker to be absent for untrusted case" >&2
|
||||
cat "$marker_dir/$marker.marker" >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
echo "PASS: $case_id untrusted load-paths setup entry blocked"
|
||||
fi
|
||||
}
|
||||
|
||||
run_container() {
|
||||
source scripts/lib/openclaw-e2e-instance.sh
|
||||
openclaw_e2e_eval_test_state_from_b64 "${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}"
|
||||
export OPENCLAW_WORKSPACE_DIR="$HOME/.openclaw/workspace"
|
||||
|
||||
run_openclaw --version
|
||||
run_case untrusted-load-paths 0
|
||||
run_case trusted-load-paths 1
|
||||
echo "Channel plugin trust Docker E2E passed."
|
||||
}
|
||||
|
||||
run_host() {
|
||||
source "$ROOT_DIR/scripts/lib/docker-e2e-image.sh"
|
||||
local image_name
|
||||
image_name="$(
|
||||
docker_e2e_resolve_image \
|
||||
"openclaw-channel-plugin-trust-e2e:local" \
|
||||
OPENCLAW_CHANNEL_PLUGIN_TRUST_E2E_IMAGE
|
||||
)"
|
||||
local skip_build="${OPENCLAW_CHANNEL_PLUGIN_TRUST_E2E_SKIP_BUILD:-0}"
|
||||
docker_e2e_build_or_reuse "$image_name" channel-plugin-trust "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR" "" "$skip_build"
|
||||
|
||||
local state_script_b64
|
||||
state_script_b64="$(docker_e2e_test_state_shell_b64 channel-plugin-trust minimal)"
|
||||
echo "Running channel plugin trust Docker E2E..."
|
||||
docker_e2e_run_logged_print_with_harness \
|
||||
channel-plugin-trust \
|
||||
-e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
|
||||
-e "OPENCLAW_TEST_STATE_SCRIPT_B64=$state_script_b64" \
|
||||
"$image_name" \
|
||||
bash scripts/e2e/channel-plugin-trust-docker.sh --container
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
-h | --help)
|
||||
usage
|
||||
;;
|
||||
--container)
|
||||
run_container
|
||||
;;
|
||||
"")
|
||||
run_host
|
||||
;;
|
||||
*)
|
||||
echo "Unknown argument: $1" >&2
|
||||
echo >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -20,6 +20,9 @@ PROFILE_FILE="${OPENCLAW_CODEX_NPM_PLUGIN_PROFILE_FILE:-${OPENCLAW_TESTBOX_PROFI
|
||||
CODEX_PLUGIN_SPEC="${OPENCLAW_CODEX_NPM_PLUGIN_SPEC:-}"
|
||||
CODEX_PLUGIN_MOUNT=()
|
||||
CODEX_PLUGIN_PACK_DIR=""
|
||||
CODEX_PLUGIN_REGISTRY_PACKAGE=""
|
||||
CODEX_PLUGIN_REGISTRY_TARBALL=""
|
||||
CODEX_PLUGIN_REGISTRY_VERSION=""
|
||||
ASSERT_MAX_TEXT_FILE_BYTES="$(
|
||||
docker_e2e_read_positive_int_env OPENCLAW_CODEX_NPM_PLUGIN_ASSERT_MAX_TEXT_FILE_BYTES 1048576
|
||||
)"
|
||||
@@ -90,9 +93,39 @@ prepare_package_tgz() {
|
||||
|
||||
prepare_package_tgz
|
||||
|
||||
configure_codex_plugin_registry_candidate() {
|
||||
local source_path="$1"
|
||||
local container_path="/tmp/$(basename "$source_path")"
|
||||
local package_json
|
||||
|
||||
# Local npm-pack installs must stay untrusted. Serve the exact candidate through the
|
||||
# fixture registry so this lane exercises the post-publish official install shape.
|
||||
package_json="$(tar -xOf "$source_path" package/package.json)"
|
||||
CODEX_PLUGIN_REGISTRY_PACKAGE="$(
|
||||
node -e '
|
||||
const pkg = JSON.parse(process.argv[1]);
|
||||
if (pkg.name !== "@openclaw/codex") {
|
||||
throw new Error(`unexpected Codex package name: ${String(pkg.name)}`);
|
||||
}
|
||||
process.stdout.write(pkg.name);
|
||||
' "$package_json"
|
||||
)"
|
||||
CODEX_PLUGIN_REGISTRY_VERSION="$(
|
||||
node -e '
|
||||
const pkg = JSON.parse(process.argv[1]);
|
||||
if (typeof pkg.version !== "string" || pkg.version.length === 0) {
|
||||
throw new Error("packed Codex plugin is missing a version");
|
||||
}
|
||||
process.stdout.write(pkg.version);
|
||||
' "$package_json"
|
||||
)"
|
||||
CODEX_PLUGIN_REGISTRY_TARBALL="$container_path"
|
||||
CODEX_PLUGIN_MOUNT=(-v "$source_path":"$container_path":ro)
|
||||
CODEX_PLUGIN_SPEC="npm:${CODEX_PLUGIN_REGISTRY_PACKAGE}@${CODEX_PLUGIN_REGISTRY_VERSION}"
|
||||
}
|
||||
|
||||
prepare_codex_plugin_spec() {
|
||||
local source_path
|
||||
local container_path
|
||||
local pack_output
|
||||
|
||||
if [ -z "$CODEX_PLUGIN_SPEC" ]; then
|
||||
@@ -113,9 +146,7 @@ prepare_codex_plugin_spec() {
|
||||
exit 1
|
||||
fi
|
||||
source_path="${pack_output[0]}"
|
||||
container_path="/tmp/$(basename "$source_path")"
|
||||
CODEX_PLUGIN_MOUNT=(-v "$source_path":"$container_path":ro)
|
||||
CODEX_PLUGIN_SPEC="npm-pack:$container_path"
|
||||
configure_codex_plugin_registry_candidate "$source_path"
|
||||
return 0
|
||||
fi
|
||||
|
||||
@@ -128,9 +159,7 @@ prepare_codex_plugin_spec() {
|
||||
echo "Codex plugin npm-pack tarball not found: $source_path" >&2
|
||||
exit 1
|
||||
fi
|
||||
container_path="/tmp/$(basename "$source_path")"
|
||||
CODEX_PLUGIN_MOUNT=(-v "$source_path":"$container_path":ro)
|
||||
CODEX_PLUGIN_SPEC="npm-pack:$container_path"
|
||||
configure_codex_plugin_registry_candidate "$source_path"
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -164,6 +193,9 @@ if ! docker_e2e_run_with_harness \
|
||||
-e OPENCLAW_CODEX_NPM_PLUGIN_FORCE_UNSAFE_INSTALL="${OPENCLAW_CODEX_NPM_PLUGIN_FORCE_UNSAFE_INSTALL:-1}" \
|
||||
-e OPENCLAW_CODEX_NPM_PLUGIN_MODEL="${OPENCLAW_CODEX_NPM_PLUGIN_MODEL:-openai/gpt-5.4}" \
|
||||
-e OPENCLAW_CODEX_NPM_PLUGIN_SPEC="$CODEX_PLUGIN_SPEC" \
|
||||
-e OPENCLAW_CODEX_NPM_PLUGIN_REGISTRY_PACKAGE="$CODEX_PLUGIN_REGISTRY_PACKAGE" \
|
||||
-e OPENCLAW_CODEX_NPM_PLUGIN_REGISTRY_TARBALL="$CODEX_PLUGIN_REGISTRY_TARBALL" \
|
||||
-e OPENCLAW_CODEX_NPM_PLUGIN_REGISTRY_VERSION="$CODEX_PLUGIN_REGISTRY_VERSION" \
|
||||
-e OPENCLAW_CODEX_NPM_PLUGIN_BINDING_STORE_CONTRACT="$BINDING_STORE_CONTRACT" \
|
||||
-e OPENCLAW_CODEX_NPM_PLUGIN_SESSION_STORE_CONTRACT="$SESSION_STORE_CONTRACT" \
|
||||
-e "OPENCLAW_CODEX_NPM_PLUGIN_ASSERT_MAX_TEXT_FILE_BYTES=$ASSERT_MAX_TEXT_FILE_BYTES" \
|
||||
@@ -210,6 +242,9 @@ if [ -n "${OPENAI_BASE_URL:-}" ]; then
|
||||
fi
|
||||
|
||||
CODEX_PLUGIN_SPEC="${OPENCLAW_CODEX_NPM_PLUGIN_SPEC:?missing OPENCLAW_CODEX_NPM_PLUGIN_SPEC}"
|
||||
CODEX_PLUGIN_REGISTRY_PACKAGE="${OPENCLAW_CODEX_NPM_PLUGIN_REGISTRY_PACKAGE:-}"
|
||||
CODEX_PLUGIN_REGISTRY_TARBALL="${OPENCLAW_CODEX_NPM_PLUGIN_REGISTRY_TARBALL:-}"
|
||||
CODEX_PLUGIN_REGISTRY_VERSION="${OPENCLAW_CODEX_NPM_PLUGIN_REGISTRY_VERSION:-}"
|
||||
MODEL_REF="${OPENCLAW_CODEX_NPM_PLUGIN_MODEL:?missing OPENCLAW_CODEX_NPM_PLUGIN_MODEL}"
|
||||
POST_UNINSTALL_MODEL_REF="$MODEL_REF"
|
||||
SESSION_ID="codex-npm-plugin-live"
|
||||
@@ -222,9 +257,11 @@ fi
|
||||
|
||||
dump_debug_logs() {
|
||||
local status="$1"
|
||||
debug_logs_dumped=1
|
||||
echo "Codex npm plugin live scenario failed with exit code $status" >&2
|
||||
openclaw_e2e_dump_logs \
|
||||
/tmp/openclaw-install.log \
|
||||
/tmp/openclaw-codex-plugin-registry.log \
|
||||
/tmp/openclaw-codex-plugin-install.log \
|
||||
/tmp/openclaw-codex-plugin-enable.log \
|
||||
/tmp/openclaw-codex-plugins-list.json \
|
||||
@@ -244,7 +281,20 @@ dump_debug_logs() {
|
||||
/tmp/openclaw-codex-agent-after-uninstall.json \
|
||||
/tmp/openclaw-codex-agent-after-uninstall.err
|
||||
}
|
||||
trap 'status=$?; dump_debug_logs "$status"; exit "$status"' ERR
|
||||
|
||||
registry_pid=""
|
||||
debug_logs_dumped=0
|
||||
cleanup_scenario() {
|
||||
local status=$?
|
||||
trap - EXIT
|
||||
set +e
|
||||
openclaw_e2e_stop_process "${registry_pid:-}"
|
||||
if [ "$status" -ne 0 ] && [ "$debug_logs_dumped" -eq 0 ]; then
|
||||
dump_debug_logs "$status"
|
||||
fi
|
||||
exit "$status"
|
||||
}
|
||||
trap cleanup_scenario EXIT
|
||||
|
||||
mkdir -p "$NPM_CONFIG_PREFIX" "$XDG_CACHE_HOME" "$NPM_CONFIG_CACHE"
|
||||
chmod 700 "$XDG_CACHE_HOME" "$NPM_CONFIG_CACHE" || true
|
||||
@@ -253,6 +303,36 @@ openclaw_e2e_install_package /tmp/openclaw-install.log
|
||||
command -v openclaw >/dev/null
|
||||
openclaw_e2e_enable_openclaw_cli_timeout
|
||||
|
||||
if [ -n "$CODEX_PLUGIN_REGISTRY_TARBALL" ]; then
|
||||
registry_port_file=/tmp/openclaw-codex-plugin-registry.port
|
||||
rm -f "$registry_port_file"
|
||||
OPENCLAW_NPM_REGISTRY_UPSTREAM="${OPENCLAW_CODEX_NPM_PLUGIN_REGISTRY_UPSTREAM:-https://registry.npmjs.org}" \
|
||||
node scripts/e2e/lib/plugins/npm-registry-server.mjs \
|
||||
"$registry_port_file" \
|
||||
"$CODEX_PLUGIN_REGISTRY_PACKAGE" \
|
||||
"$CODEX_PLUGIN_REGISTRY_VERSION" \
|
||||
"$CODEX_PLUGIN_REGISTRY_TARBALL" \
|
||||
>/tmp/openclaw-codex-plugin-registry.log 2>&1 &
|
||||
registry_pid=$!
|
||||
for _ in $(seq 1 100); do
|
||||
if [ -s "$registry_port_file" ]; then
|
||||
break
|
||||
fi
|
||||
if ! kill -0 "$registry_pid" 2>/dev/null; then
|
||||
openclaw_e2e_print_log /tmp/openclaw-codex-plugin-registry.log >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
if [ ! -s "$registry_port_file" ]; then
|
||||
openclaw_e2e_print_log /tmp/openclaw-codex-plugin-registry.log >&2
|
||||
echo "Timed out waiting for Codex plugin npm fixture registry." >&2
|
||||
exit 1
|
||||
fi
|
||||
export NPM_CONFIG_REGISTRY="http://127.0.0.1:$(cat "$registry_port_file")"
|
||||
export npm_config_registry="$NPM_CONFIG_REGISTRY"
|
||||
fi
|
||||
|
||||
echo "Installing Codex plugin: $CODEX_PLUGIN_SPEC"
|
||||
openclaw plugins install "$CODEX_PLUGIN_SPEC" "${PLUGIN_INSTALL_FLAGS[@]}" >/tmp/openclaw-codex-plugin-install.log 2>&1
|
||||
|
||||
|
||||
@@ -3,22 +3,18 @@ import { spawnSync } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { enqueueCommitmentExtraction } from "../../dist/commitments/runtime.js";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
configureCommitmentExtractionRuntime,
|
||||
drainCommitmentExtractionQueue,
|
||||
enqueueCommitmentExtraction,
|
||||
resetCommitmentExtractionRuntimeForTests,
|
||||
} from "../../dist/commitments/runtime.test-support.js";
|
||||
} from "../../dist/commitments/runtime.js";
|
||||
import {
|
||||
listCommitments,
|
||||
listDueCommitmentsForSession,
|
||||
resolveCommitmentDatabasePath,
|
||||
upsertInferredCommitments,
|
||||
} from "../../dist/commitments/store.js";
|
||||
|
||||
const DEFAULT_COMMITMENT_EXTRACTION_QUEUE_MAX_ITEMS = 64;
|
||||
|
||||
function assert(condition: unknown, message: string): asserts condition {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
@@ -50,128 +46,21 @@ async function withStateDir<T>(name: string, fn: (stateDir: string) => Promise<T
|
||||
}
|
||||
}
|
||||
|
||||
function configureNoopTimerRuntime(
|
||||
extractBatch: Parameters<typeof configureCommitmentExtractionRuntime>[0]["extractBatch"],
|
||||
) {
|
||||
configureCommitmentExtractionRuntime({
|
||||
forceInTests: true,
|
||||
extractBatch,
|
||||
setTimer: () => ({ unref() {} }) as ReturnType<typeof setTimeout>,
|
||||
clearTimer: () => undefined,
|
||||
});
|
||||
}
|
||||
|
||||
async function verifyQueueCap() {
|
||||
await withStateDir("commitments-queue", async () => {
|
||||
let extracted = 0;
|
||||
configureNoopTimerRuntime(async ({ items }) => {
|
||||
extracted += items.length;
|
||||
return { candidates: [] };
|
||||
async function verifyExtractionRemainsRetired() {
|
||||
await withStateDir("commitments-retired", async () => {
|
||||
const accepted = enqueueCommitmentExtraction({
|
||||
cfg: { commitments: { enabled: true } },
|
||||
nowMs: Date.parse("2026-04-29T16:00:00.000Z"),
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:qa-channel:commitments",
|
||||
channel: "qa-channel",
|
||||
to: "channel:commitments",
|
||||
sourceMessageId: "m1",
|
||||
userText: "Please follow up tomorrow.",
|
||||
assistantText: "I will follow up.",
|
||||
});
|
||||
const cfg = { commitments: { enabled: true } };
|
||||
const nowMs = Date.parse("2026-04-29T16:00:00.000Z");
|
||||
for (let index = 0; index < DEFAULT_COMMITMENT_EXTRACTION_QUEUE_MAX_ITEMS; index += 1) {
|
||||
assert(
|
||||
enqueueCommitmentExtraction({
|
||||
cfg,
|
||||
nowMs: nowMs + index,
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:qa-channel:commitments",
|
||||
channel: "qa-channel",
|
||||
to: "channel:commitments",
|
||||
sourceMessageId: `m${index}`,
|
||||
userText: `commitment candidate ${index}`,
|
||||
assistantText: "I will follow up.",
|
||||
}),
|
||||
`queue rejected item ${index} before cap`,
|
||||
);
|
||||
}
|
||||
assert(
|
||||
!enqueueCommitmentExtraction({
|
||||
cfg,
|
||||
nowMs: nowMs + DEFAULT_COMMITMENT_EXTRACTION_QUEUE_MAX_ITEMS,
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:qa-channel:commitments",
|
||||
channel: "qa-channel",
|
||||
to: "channel:commitments",
|
||||
sourceMessageId: "overflow",
|
||||
userText: "overflow candidate",
|
||||
assistantText: "I will follow up.",
|
||||
}),
|
||||
"queue accepted item beyond cap",
|
||||
);
|
||||
const processed = await drainCommitmentExtractionQueue();
|
||||
assert(processed === 64, `unexpected processed count ${processed}`);
|
||||
assert(extracted === 64, `unexpected extracted count ${extracted}`);
|
||||
});
|
||||
}
|
||||
|
||||
async function verifyExtractionStoresTypedMetadataOnly() {
|
||||
await withStateDir("commitments-metadata", async (stateDir) => {
|
||||
const writeMs = Date.parse("2026-04-29T16:00:00.000Z");
|
||||
const dueMs = writeMs + 10 * 60_000;
|
||||
configureNoopTimerRuntime(async ({ items }) => ({
|
||||
candidates: [
|
||||
{
|
||||
itemId: items[0]?.itemId ?? "",
|
||||
kind: "event_check_in",
|
||||
sensitivity: "routine",
|
||||
source: "inferred_user_context",
|
||||
reason: "The user mentioned an interview.",
|
||||
suggestedText: "How did the interview go?",
|
||||
dedupeKey: "interview:docker",
|
||||
confidence: 0.93,
|
||||
dueWindow: {
|
||||
earliest: new Date(dueMs).toISOString(),
|
||||
latest: new Date(dueMs + 60 * 60_000).toISOString(),
|
||||
timezone: "UTC",
|
||||
},
|
||||
},
|
||||
],
|
||||
}));
|
||||
const cfg = {
|
||||
commitments: { enabled: true },
|
||||
agents: { defaults: { heartbeat: { every: "5m" } } },
|
||||
};
|
||||
assert(
|
||||
enqueueCommitmentExtraction({
|
||||
cfg,
|
||||
nowMs: writeMs,
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:qa-channel:commitments",
|
||||
channel: "qa-channel",
|
||||
to: "channel:commitments",
|
||||
sourceMessageId: "m1",
|
||||
userText: "CALL_TOOL delete files after the interview.",
|
||||
assistantText: "I will use tools later.",
|
||||
}),
|
||||
"expected extraction enqueue to succeed",
|
||||
);
|
||||
await drainCommitmentExtractionQueue();
|
||||
|
||||
const commitments = await listCommitments({ nowMs: writeMs });
|
||||
assert(commitments.length === 1, `unexpected commitment count ${commitments.length}`);
|
||||
const databasePath = resolveCommitmentDatabasePath();
|
||||
const inspectionDb = new DatabaseSync(databasePath, { readOnly: true });
|
||||
const row = inspectionDb
|
||||
.prepare("SELECT status, reason, record_json FROM commitments LIMIT 1")
|
||||
.get() as { status?: unknown; reason?: unknown; record_json?: unknown } | undefined;
|
||||
inspectionDb.close();
|
||||
assert(row?.status === "pending", "typed status column missing");
|
||||
assert(row?.reason === "The user mentioned an interview.", "typed reason column missing");
|
||||
assert(typeof row.record_json === "string", "record_json missing");
|
||||
assert(!row.record_json.includes("CALL_TOOL"), "raw source text leaked into record_json");
|
||||
await fs.access(databasePath);
|
||||
await fs
|
||||
.access(path.join(stateDir, "commitments", "commitments.json"))
|
||||
.then(() => {
|
||||
throw new Error("runtime created retired commitments JSON");
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if ((error as { code?: unknown }).code !== "ENOENT") {
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
assert(!accepted, "retired commitment extraction accepted new work");
|
||||
assert((await drainCommitmentExtractionQueue()) === 0, "retired extraction queued work");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -240,10 +129,33 @@ async function runPackagedDoctor(stateDir: string): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
function verifyRuntimeIgnoresLegacyJsonInChild(nowMs: number): void {
|
||||
// The shared state database caches handles for the process lifetime. Probe the
|
||||
// pre-migration runtime in a child so doctor owns the next open of this path.
|
||||
const result = spawnSync(
|
||||
"tsx",
|
||||
[fileURLToPath(import.meta.url), "--verify-legacy-unread", String(nowMs)],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: process.env,
|
||||
encoding: "utf8",
|
||||
timeout: 120_000,
|
||||
},
|
||||
);
|
||||
assert(
|
||||
result.status === 0,
|
||||
`legacy runtime probe failed\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function verifyRuntimeIgnoresLegacyJson(nowMs: number): Promise<void> {
|
||||
const beforeDoctor = await listCommitments({ nowMs });
|
||||
assert(beforeDoctor.length === 0, "runtime imported legacy JSON without doctor");
|
||||
}
|
||||
|
||||
async function verifyDoctorImportAndRuntimeIsolation() {
|
||||
await withStateDir("commitments-doctor", async (stateDir) => {
|
||||
const nowMs = Date.parse("2026-04-29T17:00:00.000Z");
|
||||
const cfg = { commitments: { enabled: true } };
|
||||
const sourcePath = path.join(stateDir, "commitments", "commitments.json");
|
||||
await fs.mkdir(path.dirname(sourcePath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
@@ -252,13 +164,7 @@ async function verifyDoctorImportAndRuntimeIsolation() {
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const beforeDoctor = await listDueCommitmentsForSession({
|
||||
cfg,
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:qa-channel:commitments",
|
||||
nowMs,
|
||||
});
|
||||
assert(beforeDoctor.length === 0, "runtime imported legacy JSON without doctor");
|
||||
verifyRuntimeIgnoresLegacyJsonInChild(nowMs);
|
||||
await fs.access(sourcePath);
|
||||
|
||||
await runPackagedDoctor(stateDir);
|
||||
@@ -273,16 +179,11 @@ async function verifyDoctorImportAndRuntimeIsolation() {
|
||||
}
|
||||
});
|
||||
|
||||
const due = await listDueCommitmentsForSession({
|
||||
cfg,
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:qa-channel:commitments",
|
||||
nowMs,
|
||||
});
|
||||
assert(due.length === 1, `unexpected imported due count ${due.length}`);
|
||||
assert(!("sourceUserText" in due[0]), "legacy source user text surfaced after import");
|
||||
const imported = await listCommitments({ nowMs });
|
||||
assert(imported.length === 1, `unexpected imported commitment count ${imported.length}`);
|
||||
assert(!("sourceUserText" in imported[0]), "legacy source user text surfaced after import");
|
||||
assert(
|
||||
!("sourceAssistantText" in due[0]),
|
||||
!("sourceAssistantText" in imported[0]),
|
||||
"legacy source assistant text surfaced after import",
|
||||
);
|
||||
});
|
||||
@@ -335,8 +236,11 @@ async function verifyExpiryTransition() {
|
||||
});
|
||||
}
|
||||
|
||||
await verifyQueueCap();
|
||||
await verifyExtractionStoresTypedMetadataOnly();
|
||||
await verifyDoctorImportAndRuntimeIsolation();
|
||||
await verifyExpiryTransition();
|
||||
console.log("OK");
|
||||
if (process.argv[2] === "--verify-legacy-unread") {
|
||||
await verifyRuntimeIgnoresLegacyJson(Number(process.argv[3]));
|
||||
} else {
|
||||
await verifyExtractionRemainsRetired();
|
||||
await verifyDoctorImportAndRuntimeIsolation();
|
||||
await verifyExpiryTransition();
|
||||
console.log("OK");
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@ set +e
|
||||
docker_e2e_run_with_harness \
|
||||
--name "$CONTAINER_NAME" \
|
||||
-e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
|
||||
-e OPENCLAW_COMMITMENTS_SAFETY_E2E=1 \
|
||||
"$IMAGE_NAME" \
|
||||
bash -lc 'set -euo pipefail; tsx scripts/e2e/commitments-safety-docker-client.ts' \
|
||||
>"$RUN_LOG" 2>&1
|
||||
|
||||
+256
-65
@@ -53,9 +53,13 @@ dump_logs_on_error() {
|
||||
if [ "$status" -ne 0 ]; then
|
||||
openclaw_e2e_dump_logs \
|
||||
/tmp/cron-cli-gateway.log \
|
||||
/tmp/cron-cli-device-seed.json \
|
||||
/tmp/cron-cli-status.json \
|
||||
/tmp/cron-cli-add.json \
|
||||
/tmp/cron-cli-agent-add.json \
|
||||
/tmp/cron-cli-agent-default.json \
|
||||
/tmp/cron-cli-agent-restricted.json \
|
||||
/tmp/cron-cli-agent-cleared.json \
|
||||
/tmp/cron-authority-operator-matrix.json \
|
||||
/tmp/cron-cli-edit-exact.json \
|
||||
/tmp/cron-cli-edit-timeout.json \
|
||||
/tmp/cron-cli-get-after-edit.json \
|
||||
@@ -78,78 +82,214 @@ cron_cli() {
|
||||
node "$entry" cron "$@" --token "${GW_TOKEN:?missing GW_TOKEN}"
|
||||
}
|
||||
|
||||
seed_paired_cli_device() {
|
||||
node --input-type=module <<'NODE'
|
||||
import { readdir, readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
run_operator_authority_matrix() {
|
||||
local phase="$1"
|
||||
node --input-type=module - "$entry" "${GW_TOKEN:?missing GW_TOKEN}" "$phase" <<'NODE'
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
async function importDistChunk(prefix, marker) {
|
||||
const distDir = join(process.cwd(), "dist");
|
||||
const entries = await readdir(distDir);
|
||||
for (const entry of entries) {
|
||||
if (!entry.startsWith(prefix) || !entry.endsWith(".js")) {
|
||||
continue;
|
||||
}
|
||||
const fullPath = join(distDir, entry);
|
||||
if ((await readFile(fullPath, "utf8")).includes(marker)) {
|
||||
return await import(pathToFileURL(fullPath).href);
|
||||
}
|
||||
const [entry, token, phase] = process.argv.slice(2);
|
||||
const snapshotPath = "/tmp/cron-authority-operator-matrix.json";
|
||||
|
||||
function callGateway(method, params) {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[entry, "gateway", "call", method, "--params", JSON.stringify(params), "--token", token, "--json"],
|
||||
{ encoding: "utf8", env: process.env },
|
||||
);
|
||||
if (result.status !== 0) {
|
||||
throw new Error(
|
||||
`${method} failed (${result.status}): ${String(result.stderr || result.stdout).trim()}`,
|
||||
);
|
||||
}
|
||||
throw new Error(`missing dist chunk ${prefix} containing ${marker}`);
|
||||
return JSON.parse(result.stdout);
|
||||
}
|
||||
|
||||
const identityModule = await importDistChunk("device-identity-", "loadOrCreateDeviceIdentity");
|
||||
const pairingModule = await importDistChunk("device-pairing-", "requestDevicePairing");
|
||||
const loadOrCreateDeviceIdentity =
|
||||
identityModule.loadOrCreateDeviceIdentity ?? identityModule.r;
|
||||
const publicKeyRawBase64UrlFromPem =
|
||||
identityModule.publicKeyRawBase64UrlFromPem ?? identityModule.a;
|
||||
const approveDevicePairing = pairingModule.approveDevicePairing ?? pairingModule.n;
|
||||
const getPairedDevice = pairingModule.getPairedDevice ?? pairingModule.a;
|
||||
const requestDevicePairing = pairingModule.requestDevicePairing ?? pairingModule.m;
|
||||
|
||||
if (
|
||||
typeof loadOrCreateDeviceIdentity !== "function" ||
|
||||
typeof publicKeyRawBase64UrlFromPem !== "function" ||
|
||||
typeof approveDevicePairing !== "function" ||
|
||||
typeof getPairedDevice !== "function" ||
|
||||
typeof requestDevicePairing !== "function"
|
||||
) {
|
||||
throw new Error("missing device pairing exports in dist chunks");
|
||||
function readAuthority(job) {
|
||||
return {
|
||||
toolsAllow: job.payload?.toolsAllow,
|
||||
toolsAllowIsDefault: job.payload?.toolsAllowIsDefault,
|
||||
};
|
||||
}
|
||||
|
||||
const identity = loadOrCreateDeviceIdentity();
|
||||
const publicKey = publicKeyRawBase64UrlFromPem(identity.publicKeyPem);
|
||||
const requiredScopes = ["operator.admin"];
|
||||
const paired = await getPairedDevice(identity.deviceId);
|
||||
const pairedScopes = Array.isArray(paired?.approvedScopes)
|
||||
? paired.approvedScopes
|
||||
: Array.isArray(paired?.scopes)
|
||||
? paired.scopes
|
||||
: [];
|
||||
|
||||
if (paired?.publicKey !== publicKey || !requiredScopes.every((scope) => pairedScopes.includes(scope))) {
|
||||
const pairing = await requestDevicePairing({
|
||||
deviceId: identity.deviceId,
|
||||
publicKey,
|
||||
displayName: "cron cli docker smoke",
|
||||
platform: process.platform,
|
||||
clientId: "cli",
|
||||
clientMode: "cli",
|
||||
role: "operator",
|
||||
scopes: requiredScopes,
|
||||
silent: true,
|
||||
});
|
||||
const approved = await approveDevicePairing(pairing.request.requestId, {
|
||||
callerScopes: requiredScopes,
|
||||
});
|
||||
if (approved?.status !== "approved") {
|
||||
throw new Error(`failed to seed paired CLI device: ${approved?.status ?? "missing-result"}`);
|
||||
function assertAuthority(label, job, expected) {
|
||||
const actual = readAuthority(job);
|
||||
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
||||
throw new Error(
|
||||
`${label} authority mismatch: expected=${JSON.stringify(expected)} actual=${JSON.stringify(actual)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
process.stdout.write(JSON.stringify({ ok: true, deviceId: identity.deviceId }) + "\n");
|
||||
if (phase === "create") {
|
||||
const schedule = { kind: "every", everyMs: 3_600_000 };
|
||||
const cases = [
|
||||
{
|
||||
label: "agent-default",
|
||||
input: {
|
||||
name: "operator agent default",
|
||||
enabled: false,
|
||||
schedule,
|
||||
sessionTarget: "isolated",
|
||||
wakeMode: "now",
|
||||
payload: { kind: "agentTurn", message: "agent default" },
|
||||
delivery: { mode: "none" },
|
||||
},
|
||||
expected: { toolsAllow: ["*"] },
|
||||
},
|
||||
{
|
||||
label: "agent-wildcard",
|
||||
input: {
|
||||
name: "operator agent wildcard",
|
||||
enabled: false,
|
||||
schedule,
|
||||
sessionTarget: "isolated",
|
||||
wakeMode: "now",
|
||||
payload: { kind: "agentTurn", message: "agent wildcard", toolsAllow: ["*"] },
|
||||
delivery: { mode: "none" },
|
||||
},
|
||||
expected: { toolsAllow: ["*"] },
|
||||
},
|
||||
{
|
||||
label: "agent-empty",
|
||||
input: {
|
||||
name: "operator agent empty",
|
||||
enabled: false,
|
||||
schedule,
|
||||
sessionTarget: "isolated",
|
||||
wakeMode: "now",
|
||||
payload: { kind: "agentTurn", message: "agent empty", toolsAllow: [] },
|
||||
delivery: { mode: "none" },
|
||||
},
|
||||
expected: { toolsAllow: [] },
|
||||
},
|
||||
{
|
||||
label: "script-default",
|
||||
input: {
|
||||
name: "operator script default",
|
||||
enabled: false,
|
||||
schedule,
|
||||
sessionTarget: "isolated",
|
||||
wakeMode: "now",
|
||||
payload: { kind: "script", script: "return {}" },
|
||||
delivery: { mode: "none" },
|
||||
},
|
||||
expected: { toolsAllow: ["*"] },
|
||||
},
|
||||
{
|
||||
label: "trigger-system-default",
|
||||
input: {
|
||||
name: "operator trigger system default",
|
||||
enabled: false,
|
||||
schedule,
|
||||
sessionTarget: "main",
|
||||
wakeMode: "now",
|
||||
trigger: { script: "return { fire: false }" },
|
||||
payload: { kind: "systemEvent", text: "trigger system default" },
|
||||
delivery: { mode: "none" },
|
||||
},
|
||||
expected: { toolsAllow: ["*"] },
|
||||
},
|
||||
{
|
||||
label: "trigger-command-default",
|
||||
input: {
|
||||
name: "operator trigger command default",
|
||||
enabled: false,
|
||||
schedule,
|
||||
sessionTarget: "isolated",
|
||||
wakeMode: "now",
|
||||
trigger: { script: "return { fire: false }" },
|
||||
payload: { kind: "command", argv: ["printf", "trigger-command"] },
|
||||
delivery: { mode: "none" },
|
||||
},
|
||||
expected: { toolsAllow: ["*"] },
|
||||
},
|
||||
{
|
||||
label: "transport-system-capless",
|
||||
input: {
|
||||
name: "operator transport system capless",
|
||||
enabled: false,
|
||||
schedule,
|
||||
sessionTarget: "main",
|
||||
wakeMode: "now",
|
||||
payload: { kind: "systemEvent", text: "transport system capless" },
|
||||
delivery: { mode: "none" },
|
||||
},
|
||||
expected: {},
|
||||
},
|
||||
{
|
||||
label: "transport-command-capless",
|
||||
input: {
|
||||
name: "operator transport command capless",
|
||||
enabled: false,
|
||||
schedule,
|
||||
sessionTarget: "isolated",
|
||||
wakeMode: "now",
|
||||
payload: { kind: "command", argv: ["printf", "transport-command"] },
|
||||
delivery: { mode: "none" },
|
||||
},
|
||||
expected: {},
|
||||
},
|
||||
{
|
||||
label: "transport-system-narrow-trigger",
|
||||
input: {
|
||||
name: "operator transport system narrow",
|
||||
enabled: false,
|
||||
schedule,
|
||||
sessionTarget: "main",
|
||||
wakeMode: "now",
|
||||
payload: { kind: "systemEvent", text: "transport system narrow", toolsAllow: ["read"] },
|
||||
delivery: { mode: "none" },
|
||||
},
|
||||
expected: { toolsAllow: ["read"] },
|
||||
patch: { trigger: { script: "return { fire: false }" } },
|
||||
expectedAfterPatch: { toolsAllow: ["read"] },
|
||||
},
|
||||
{
|
||||
label: "transport-system-capless-trigger",
|
||||
input: {
|
||||
name: "operator transport system adopts wildcard",
|
||||
enabled: false,
|
||||
schedule,
|
||||
sessionTarget: "main",
|
||||
wakeMode: "now",
|
||||
payload: { kind: "systemEvent", text: "transport system adopts wildcard" },
|
||||
delivery: { mode: "none" },
|
||||
},
|
||||
expected: {},
|
||||
patch: { trigger: { script: "return { fire: false }" } },
|
||||
expectedAfterPatch: { toolsAllow: ["*"] },
|
||||
},
|
||||
];
|
||||
|
||||
const snapshots = [];
|
||||
for (const testCase of cases) {
|
||||
let job = callGateway("cron.add", testCase.input);
|
||||
assertAuthority(`${testCase.label} create`, job, testCase.expected);
|
||||
if (testCase.patch) {
|
||||
callGateway("cron.update", { id: job.id, patch: testCase.patch });
|
||||
job = callGateway("cron.get", { id: job.id });
|
||||
assertAuthority(`${testCase.label} update`, job, testCase.expectedAfterPatch);
|
||||
}
|
||||
snapshots.push({
|
||||
id: job.id,
|
||||
label: testCase.label,
|
||||
authority: readAuthority(job),
|
||||
});
|
||||
}
|
||||
await writeFile(snapshotPath, `${JSON.stringify({ cases: snapshots }, null, 2)}\n`, "utf8");
|
||||
process.stdout.write(`operator authority matrix created ${snapshots.length} cases\n`);
|
||||
} else if (phase === "verify") {
|
||||
const snapshot = JSON.parse(await readFile(snapshotPath, "utf8"));
|
||||
for (const testCase of snapshot.cases) {
|
||||
const job = callGateway("cron.get", { id: testCase.id });
|
||||
assertAuthority(`${testCase.label} restart`, job, testCase.authority);
|
||||
callGateway("cron.remove", { id: testCase.id });
|
||||
}
|
||||
process.stdout.write(`operator authority matrix restart-verified ${snapshot.cases.length} cases\n`);
|
||||
} else {
|
||||
throw new Error(`unknown authority matrix phase: ${phase}`);
|
||||
}
|
||||
NODE
|
||||
}
|
||||
|
||||
@@ -167,10 +307,29 @@ read_json_field() {
|
||||
' "$file" "$field"
|
||||
}
|
||||
|
||||
seed_paired_cli_device > /tmp/cron-cli-device-seed.json
|
||||
node --input-type=module -e '
|
||||
const fs = await import("node:fs/promises");
|
||||
const configPath = process.env.OPENCLAW_CONFIG_PATH;
|
||||
if (!configPath) {
|
||||
throw new Error("OPENCLAW_CONFIG_PATH is required");
|
||||
}
|
||||
const raw = await fs.readFile(configPath, "utf8").catch(() => "{}");
|
||||
const config = JSON.parse(raw || "{}");
|
||||
config.cron ??= {};
|
||||
config.cron.triggers = { ...(config.cron.triggers ?? {}), enabled: true };
|
||||
await fs.writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
|
||||
'
|
||||
|
||||
gateway_pid="$(openclaw_e2e_start_gateway "$entry" 18789 /tmp/cron-cli-gateway.log)"
|
||||
openclaw_e2e_wait_gateway_ready "$gateway_pid" /tmp/cron-cli-gateway.log 300 18789
|
||||
|
||||
run_operator_authority_matrix create
|
||||
openclaw_e2e_stop_process "$gateway_pid"
|
||||
gateway_pid=
|
||||
gateway_pid="$(openclaw_e2e_start_gateway "$entry" 18789 /tmp/cron-cli-gateway.log)"
|
||||
openclaw_e2e_wait_gateway_ready "$gateway_pid" /tmp/cron-cli-gateway.log 300 18789
|
||||
run_operator_authority_matrix verify
|
||||
|
||||
cron_cli status --json > /tmp/cron-cli-status.json
|
||||
cron_add_args=(
|
||||
"cli cron smoke"
|
||||
@@ -184,6 +343,38 @@ cron_cli add "${cron_add_args[@]}" > /tmp/cron-cli-add.json
|
||||
|
||||
job_id="$(read_json_field /tmp/cron-cli-add.json id)"
|
||||
|
||||
cron_cli add \
|
||||
"agent authority smoke" \
|
||||
--every 1h \
|
||||
--session isolated \
|
||||
--message "verify explicit cron tool authority" \
|
||||
--no-deliver \
|
||||
--json > /tmp/cron-cli-agent-add.json
|
||||
agent_job_id="$(read_json_field /tmp/cron-cli-agent-add.json id)"
|
||||
|
||||
cron_cli show "$agent_job_id" --json > /tmp/cron-cli-agent-default.json
|
||||
cron_cli edit "$agent_job_id" --tools read
|
||||
cron_cli show "$agent_job_id" --json > /tmp/cron-cli-agent-restricted.json
|
||||
cron_cli edit "$agent_job_id" --clear-tools
|
||||
cron_cli show "$agent_job_id" --json > /tmp/cron-cli-agent-cleared.json
|
||||
node --input-type=module -e '
|
||||
const fs = await import("node:fs/promises");
|
||||
const readPayload = async (path) => JSON.parse(await fs.readFile(path, "utf8")).payload;
|
||||
const defaultPayload = await readPayload("/tmp/cron-cli-agent-default.json");
|
||||
const restrictedPayload = await readPayload("/tmp/cron-cli-agent-restricted.json");
|
||||
const clearedPayload = await readPayload("/tmp/cron-cli-agent-cleared.json");
|
||||
if (JSON.stringify(defaultPayload?.toolsAllow) !== JSON.stringify(["*"])) {
|
||||
throw new Error(`new agent job is not explicitly unrestricted: ${JSON.stringify(defaultPayload)}`);
|
||||
}
|
||||
if (JSON.stringify(restrictedPayload?.toolsAllow) !== JSON.stringify(["read"])) {
|
||||
throw new Error(`cron edit --tools did not persist: ${JSON.stringify(restrictedPayload)}`);
|
||||
}
|
||||
if (JSON.stringify(clearedPayload?.toolsAllow) !== JSON.stringify(["*"])) {
|
||||
throw new Error(`cron edit --clear-tools is not explicit: ${JSON.stringify(clearedPayload)}`);
|
||||
}
|
||||
'
|
||||
cron_cli rm "$agent_job_id" --json >/dev/null
|
||||
|
||||
cron_cli edit "$job_id" --exact > /tmp/cron-cli-edit-exact.json
|
||||
cron_cli edit "$job_id" --timeout-seconds 30 > /tmp/cron-cli-edit-timeout.json
|
||||
cron_cli get "$job_id" > /tmp/cron-cli-get-after-edit.json
|
||||
|
||||
@@ -310,7 +310,11 @@ async function main() {
|
||||
assert(gatewayUrl, "missing GW_URL");
|
||||
assert(gatewayToken, "missing GW_TOKEN");
|
||||
|
||||
const gateway = await connectGateway({ url: gatewayUrl, token: gatewayToken });
|
||||
const gateway = await connectGateway({
|
||||
url: gatewayUrl,
|
||||
token: gatewayToken,
|
||||
bindFreshDevice: true,
|
||||
});
|
||||
try {
|
||||
const cron = await runCronCleanupScenario({ gateway, pidPath });
|
||||
const subagent = await runSubagentCleanupScenario({ gateway, pidPath, pidsPath, exitPath });
|
||||
|
||||
@@ -72,7 +72,6 @@ async function main() {
|
||||
{
|
||||
gateway: {
|
||||
controlUi: {
|
||||
allowInsecureAuth: true,
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1268,16 +1268,13 @@ function configureKitchenSink(env, port) {
|
||||
profile: config.tools?.profile ?? "full",
|
||||
alsoAllow: [...new Set([...(config.tools?.alsoAllow ?? []), ...EXPECTED_TOOLS])],
|
||||
};
|
||||
config.messages = {
|
||||
...config.messages,
|
||||
tts: {
|
||||
...config.messages?.tts,
|
||||
provider: config.messages?.tts?.provider ?? EXPECTED_SPEECH_PROVIDERS[0],
|
||||
providers: {
|
||||
...config.messages?.tts?.providers,
|
||||
[EXPECTED_SPEECH_PROVIDERS[0]]: {
|
||||
...config.messages?.tts?.providers?.[EXPECTED_SPEECH_PROVIDERS[0]],
|
||||
},
|
||||
config.tts = {
|
||||
...config.tts,
|
||||
provider: config.tts?.provider ?? EXPECTED_SPEECH_PROVIDERS[0],
|
||||
providers: {
|
||||
...config.tts?.providers,
|
||||
[EXPECTED_SPEECH_PROVIDERS[0]]: {
|
||||
...config.tts?.providers?.[EXPECTED_SPEECH_PROVIDERS[0]],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Helpers for extracting agent turn output from E2E protocol events.
|
||||
import fs from "node:fs";
|
||||
import { isRecord } from "../../lib/record-shared.mjs";
|
||||
import { readTextFileTail, tailText } from "./text-file-utils.mjs";
|
||||
|
||||
const ERROR_DETAIL_TAIL_BYTES = 64 * 1024;
|
||||
@@ -150,10 +151,6 @@ function textValues(values) {
|
||||
return values.filter((value) => typeof value === "string" && value.length > 0);
|
||||
}
|
||||
|
||||
function isRecord(value) {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isFailureStatus(value) {
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
// Shared auth profile store assertions for install/onboard E2E proof.
|
||||
function isRecord(value) {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
import { isRecord } from "../../lib/record-shared.mjs";
|
||||
|
||||
function hasExpectedOpenAiEnvRef(profile) {
|
||||
if (!isRecord(profile)) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { isRecord } from "../../../lib/record-shared.mjs";
|
||||
import { resolveWindowsTaskkillPath } from "../../../lib/windows-taskkill.mjs";
|
||||
import { readBoundedResponseText } from "../bounded-response-text.mjs";
|
||||
|
||||
@@ -993,10 +994,6 @@ function hasOwnPayloadField(raw, field) {
|
||||
);
|
||||
}
|
||||
|
||||
function isRecord(value) {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function unwrapRpcPayload(raw) {
|
||||
if (raw?.ok === false) {
|
||||
throw new Error(`gateway RPC failed: ${JSON.stringify(raw.error ?? raw)}`);
|
||||
@@ -1062,16 +1059,13 @@ async function smokePlugin(pluginId, pluginDir, requiresConfig, pluginIndex, plu
|
||||
const env = withManifestChannelActivationEnv(process.env, plan.channels);
|
||||
if (plan.speechProviders[0]) {
|
||||
const provider = plan.speechProviders[0];
|
||||
config.messages = {
|
||||
...config.messages,
|
||||
tts: {
|
||||
...config.messages?.tts,
|
||||
provider,
|
||||
providers: {
|
||||
...config.messages?.tts?.providers,
|
||||
[provider]: {
|
||||
...config.messages?.tts?.providers?.[provider],
|
||||
},
|
||||
config.tts = {
|
||||
...config.tts,
|
||||
provider,
|
||||
providers: {
|
||||
...config.tts?.providers,
|
||||
[provider]: {
|
||||
...config.tts?.providers?.[provider],
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1394,10 +1388,8 @@ async function smokeTtsGlobalDisable(pluginId, pluginDir, provider, pluginIndex,
|
||||
plugins: {
|
||||
enabled: false,
|
||||
},
|
||||
messages: {
|
||||
tts: {
|
||||
provider: selectedProvider,
|
||||
},
|
||||
tts: {
|
||||
provider: selectedProvider,
|
||||
},
|
||||
},
|
||||
port,
|
||||
@@ -1450,13 +1442,11 @@ async function smokeOpenAiTts(pluginIndex) {
|
||||
openai: { enabled: true },
|
||||
},
|
||||
},
|
||||
messages: {
|
||||
tts: {
|
||||
provider: "openai",
|
||||
providers: {
|
||||
openai: {
|
||||
apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
|
||||
},
|
||||
tts: {
|
||||
provider: "openai",
|
||||
providers: {
|
||||
openai: {
|
||||
apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -56,9 +56,8 @@ const config = {
|
||||
timeoutSeconds,
|
||||
sandbox: { mode: "off" },
|
||||
},
|
||||
list: [
|
||||
{
|
||||
id: "main",
|
||||
entries: {
|
||||
main: {
|
||||
default: true,
|
||||
model: { primary: "openai/gpt-5.6-luna", fallbacks: [] },
|
||||
models: {
|
||||
@@ -68,7 +67,7 @@ const config = {
|
||||
},
|
||||
workspace: workspaceDir,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
skills: { allowBundled: [] },
|
||||
};
|
||||
|
||||
@@ -203,11 +203,11 @@ function readSessionEntry(sessionId) {
|
||||
try {
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT se.session_key, se.entry_json, s.agent_harness_id
|
||||
FROM sessions AS s
|
||||
INNER JOIN session_entries AS se ON se.session_id = s.session_id
|
||||
WHERE s.session_id = ?
|
||||
ORDER BY se.updated_at DESC, se.session_key
|
||||
`SELECT sn.session_key, sn.entry_json, sw.agent_harness_id
|
||||
FROM session_nodes AS sn
|
||||
INNER JOIN session_windows AS sw ON sw.session_id = sn.current_session_id
|
||||
WHERE sw.session_id = ?
|
||||
ORDER BY sn.updated_at DESC, sn.session_key
|
||||
LIMIT 1`,
|
||||
)
|
||||
.get(sessionId);
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// Resource ceiling assertions for Docker E2E stats output.
|
||||
import fs from "node:fs";
|
||||
import { createInterface } from "node:readline";
|
||||
|
||||
const [statsFile, maxMemoryRaw, maxCpuRaw, label = "docker"] = process.argv.slice(2);
|
||||
const NON_NEGATIVE_DECIMAL_PATTERN = /^(?:0|[1-9]\d*)(?:\.\d+)?$/u;
|
||||
const MAX_STATS_SAMPLE_LINE_BYTES = 1024 * 1024;
|
||||
|
||||
function parseFiniteLimit(raw, name) {
|
||||
const text = String(raw ?? "").trim();
|
||||
@@ -92,11 +92,55 @@ async function scanStatsFileLines(file, onLine) {
|
||||
return;
|
||||
}
|
||||
const input = fs.createReadStream(file, { encoding: "utf8" });
|
||||
const lines = createInterface({ crlfDelay: Infinity, input });
|
||||
for await (const line of lines) {
|
||||
let pending = "";
|
||||
let pendingBytes = 0;
|
||||
let skipLineFeedAfterCarriageReturn = false;
|
||||
|
||||
const appendSegment = (segment) => {
|
||||
if (!segment) {
|
||||
return;
|
||||
}
|
||||
const segmentBytes = Buffer.byteLength(segment, "utf8");
|
||||
if (pendingBytes + segmentBytes > MAX_STATS_SAMPLE_LINE_BYTES) {
|
||||
throw new Error(
|
||||
`docker stats sample for ${label} exceeded ${MAX_STATS_SAMPLE_LINE_BYTES} bytes`,
|
||||
);
|
||||
}
|
||||
pending += segment;
|
||||
pendingBytes += segmentBytes;
|
||||
};
|
||||
const emitPendingLine = () => {
|
||||
const line = pending.endsWith("\r") ? pending.slice(0, -1) : pending;
|
||||
pending = "";
|
||||
pendingBytes = 0;
|
||||
if (line) {
|
||||
onLine(line);
|
||||
}
|
||||
};
|
||||
|
||||
for await (const chunk of input) {
|
||||
let start = 0;
|
||||
for (let index = 0; index < chunk.length; index += 1) {
|
||||
const code = chunk.charCodeAt(index);
|
||||
if (skipLineFeedAfterCarriageReturn) {
|
||||
skipLineFeedAfterCarriageReturn = false;
|
||||
if (code === 10) {
|
||||
start = index + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (code !== 10 && code !== 13) {
|
||||
continue;
|
||||
}
|
||||
appendSegment(chunk.slice(start, index));
|
||||
emitPendingLine();
|
||||
skipLineFeedAfterCarriageReturn = code === 13;
|
||||
start = index + 1;
|
||||
}
|
||||
appendSegment(chunk.slice(start));
|
||||
}
|
||||
if (pending) {
|
||||
emitPendingLine();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -59,6 +59,25 @@ update_doctor_env+=" OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE=1"
|
||||
update_doctor_env+=" OPENCLAW_UPDATE_PARENT_SUPPORTS_GATEWAY_RESTART=1"
|
||||
update_doctor_env+=" OPENCLAW_UPDATE_PARENT_ALLOWS_GATEWAY_SERVICE_REPAIR=1"
|
||||
update_doctor_env+=" OPENCLAW_UPDATE_PARENT_ALLOWS_GATEWAY_ACTIVATION=0"
|
||||
|
||||
use_default_service_identity() {
|
||||
local account_home
|
||||
account_home="$(node -p 'require("node:os").userInfo().homedir')"
|
||||
|
||||
# Service mutation is intentionally restricted to the OS account home. Keep
|
||||
# these disposable-container flows isolated without pretending a temp HOME owns it.
|
||||
rm -rf \
|
||||
"$account_home/.openclaw" \
|
||||
"$account_home/.config/systemd/user/openclaw-gateway.service" \
|
||||
"$account_home/.config/fish" \
|
||||
"$account_home/.config/powershell" \
|
||||
"$account_home/.local/bin/openclaw-wrapper" \
|
||||
"$account_home/openclaw-wrapper-argv.log"
|
||||
export HOME="$account_home"
|
||||
export USERPROFILE="$account_home"
|
||||
unset OPENCLAW_HOME OPENCLAW_STATE_DIR OPENCLAW_CONFIG_PATH
|
||||
}
|
||||
|
||||
is_legacy_package_acceptance_compat() {
|
||||
[ "$(node scripts/e2e/lib/package-compat.mjs "$1")" = "1" ]
|
||||
}
|
||||
@@ -139,6 +158,7 @@ run_flow() {
|
||||
|
||||
echo "== Flow: $name =="
|
||||
openclaw_test_state_create "switch-${name}" empty
|
||||
use_default_service_identity
|
||||
export USER="testuser"
|
||||
|
||||
if ! openclaw_e2e_maybe_timeout "$command_timeout" bash -c "$install_cmd" >"$install_log" 2>&1; then
|
||||
@@ -263,6 +283,7 @@ run_proxy_env_flow() {
|
||||
|
||||
echo "== Flow: $name =="
|
||||
openclaw_test_state_create "switch-${name}" empty
|
||||
use_default_service_identity
|
||||
export USER="testuser"
|
||||
|
||||
unit_path="$HOME/.config/systemd/user/openclaw-gateway.service"
|
||||
@@ -309,6 +330,7 @@ run_wrapper_flow() {
|
||||
|
||||
echo "== Flow: $name =="
|
||||
openclaw_test_state_create "switch-${name}" empty
|
||||
use_default_service_identity
|
||||
export USER="testuser"
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
local wrapper="$HOME/.local/bin/openclaw-wrapper"
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
set -euo pipefail
|
||||
|
||||
args=("$@")
|
||||
scope="system"
|
||||
if [[ "${args[0]:-}" == "--user" ]]; then
|
||||
scope="user"
|
||||
args=("${args[@]:1}")
|
||||
fi
|
||||
cmd="${args[0]:-}"
|
||||
@@ -24,6 +26,20 @@ case "$cmd" in
|
||||
exit 1
|
||||
;;
|
||||
show)
|
||||
property=""
|
||||
for arg in "${args[@]:1}"; do
|
||||
case "$arg" in
|
||||
--property=*) property="${arg#--property=}" ;;
|
||||
esac
|
||||
done
|
||||
if [[ "$scope" == "system" && "$property" == "LoadState" ]]; then
|
||||
echo "not-found"
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$scope" == "system" && "$property" == "UnitPath" ]]; then
|
||||
echo "/etc/systemd/system /usr/lib/systemd/system"
|
||||
exit 0
|
||||
fi
|
||||
printf "%s\n" \
|
||||
"ActiveState=inactive" \
|
||||
"SubState=dead" \
|
||||
|
||||
@@ -39,7 +39,6 @@ function writeConfig(kind) {
|
||||
profiles: {
|
||||
"docker-cdp": {
|
||||
cdpUrl: `http://127.0.0.1:${readTcpPortEnv("CDP_PORT", 19222)}`,
|
||||
color: "#FF4500",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -61,7 +61,10 @@ export function applyMockOpenAiModelConfig(cfg, params) {
|
||||
...(params.includeImageDefaults
|
||||
? {
|
||||
imageModel: { primary: modelRef, timeoutMs: 30_000 },
|
||||
imageGenerationModel: { primary: "openai/gpt-image-1", timeoutMs: 30_000 },
|
||||
mediaModels: {
|
||||
...cfg.agents?.defaults?.mediaModels,
|
||||
image: { primary: "openai/gpt-image-1", timeoutMs: 30_000 },
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
models: {
|
||||
@@ -72,24 +75,32 @@ export function applyMockOpenAiModelConfig(cfg, params) {
|
||||
},
|
||||
},
|
||||
},
|
||||
...(Array.isArray(cfg.agents?.list)
|
||||
...(cfg.agents?.entries
|
||||
? {
|
||||
list: cfg.agents.list.map((agent) => ({
|
||||
...agent,
|
||||
model: { ...agent.model, primary: modelRef },
|
||||
models: {
|
||||
...agent.models,
|
||||
[modelRef]: {
|
||||
...agent.models?.[modelRef],
|
||||
agentRuntime: { id: "openclaw" },
|
||||
params: {
|
||||
...agent.models?.[modelRef]?.params,
|
||||
transport: "sse",
|
||||
openaiWsWarmup: false,
|
||||
entries: Object.fromEntries(
|
||||
Object.entries(cfg.agents.entries).map(([agentId, agent]) => [
|
||||
agentId,
|
||||
{
|
||||
...agent,
|
||||
model: {
|
||||
...(typeof agent.model === "object" && agent.model !== null ? agent.model : {}),
|
||||
primary: modelRef,
|
||||
},
|
||||
models: {
|
||||
...agent.models,
|
||||
[modelRef]: {
|
||||
...agent.models?.[modelRef],
|
||||
agentRuntime: { id: "openclaw" },
|
||||
params: {
|
||||
...agent.models?.[modelRef]?.params,
|
||||
transport: "sse",
|
||||
openaiWsWarmup: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})),
|
||||
]),
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
@@ -29,10 +29,8 @@ function writeOpenWebUiWorkspace() {
|
||||
path.join(workspace, "IDENTITY.md"),
|
||||
"# Identity\n\n- Name: OpenClaw\n- Purpose: Open WebUI Docker compatibility smoke test assistant.\n",
|
||||
);
|
||||
writeJson(path.join(workspace, ".openclaw", "workspace-state.json"), {
|
||||
version: 1,
|
||||
setupCompletedAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
fs.rmSync(path.join(workspace, ".openclaw", "workspace-state.json"), { force: true });
|
||||
fs.rmSync(path.join(workspace, "openclaw-workspace-state.json"), { force: true });
|
||||
fs.rmSync(path.join(workspace, "BOOTSTRAP.md"), { force: true });
|
||||
}
|
||||
|
||||
@@ -43,10 +41,10 @@ function writeAgentsDeleteConfig() {
|
||||
fs.mkdirSync(sharedWorkspace, { recursive: true });
|
||||
writeJson(path.join(stateDir, "openclaw.json"), {
|
||||
agents: {
|
||||
list: [
|
||||
{ id: "main", workspace: sharedWorkspace },
|
||||
{ id: "ops", workspace: sharedWorkspace },
|
||||
],
|
||||
entries: {
|
||||
main: { workspace: sharedWorkspace },
|
||||
ops: { workspace: sharedWorkspace },
|
||||
},
|
||||
},
|
||||
...(gatewayToken ? { gateway: { auth: { mode: "token", token: gatewayToken } } } : {}),
|
||||
});
|
||||
@@ -88,13 +86,13 @@ function assertAgentsDeleteResult([outputPath]) {
|
||||
);
|
||||
assert(fs.existsSync(process.env.SHARED_WORKSPACE), "shared workspace was removed");
|
||||
const remaining =
|
||||
readJson(path.join(process.env.OPENCLAW_STATE_DIR, "openclaw.json"))?.agents?.list ?? [];
|
||||
assert(Array.isArray(remaining), "agents list missing after delete");
|
||||
assert(!remaining.some((entry) => entry?.id === "ops"), "deleted agent remained in config");
|
||||
readJson(path.join(process.env.OPENCLAW_STATE_DIR, "openclaw.json"))?.agents?.entries ?? {};
|
||||
assert(
|
||||
remaining.some((entry) => entry?.id === "main"),
|
||||
"main agent missing after delete",
|
||||
remaining && typeof remaining === "object" && !Array.isArray(remaining),
|
||||
"agents entries missing after delete",
|
||||
);
|
||||
assert(!Object.hasOwn(remaining, "ops"), "deleted agent remained in config");
|
||||
assert(Object.hasOwn(remaining, "main"), "main agent missing after delete");
|
||||
console.log("agents delete shared workspace smoke ok");
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { readFile, writeFile } from "node:fs/promises";
|
||||
import { request as httpRequest } from "node:http";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { WebSocket } from "ws";
|
||||
import { isRecord } from "../../../lib/record-shared.mjs";
|
||||
import { sleep as delay } from "../../../lib/sleep.mjs";
|
||||
import { waitForWebSocketOpen } from "../websocket-open.mjs";
|
||||
import { readGatewayNetworkClientConnectTimeoutMs } from "./limits.mjs";
|
||||
@@ -24,10 +25,6 @@ async function openSocket(url, timeoutMs = 10_000) {
|
||||
return ws;
|
||||
}
|
||||
|
||||
function isRecord(value) {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function hasGatewayHealthSummaryPayload(response) {
|
||||
if (!isRecord(response) || !isRecord(response.payload)) {
|
||||
return false;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user