Compare commits

...

3 Commits

Author SHA1 Message Date
Vincent Koc
85b0c5aea5 fix(ci): preserve gateway watch ready detection 2026-04-17 10:37:40 -07:00
Vincent Koc
6edfb61d29 fix(ci): scope extension boundary package checks 2026-04-17 10:29:38 -07:00
Vincent Koc
eb5caaf5b9 fix(ci): slim gateway watch regression harness 2026-04-17 10:22:56 -07:00
6 changed files with 393 additions and 26 deletions

View File

@@ -36,6 +36,7 @@ jobs:
run_windows: ${{ steps.manifest.outputs.run_windows }}
has_changed_extensions: ${{ steps.manifest.outputs.has_changed_extensions }}
changed_extensions_matrix: ${{ steps.manifest.outputs.changed_extensions_matrix }}
changed_paths_json: ${{ steps.manifest.outputs.changed_paths_json }}
run_build_artifacts: ${{ steps.manifest.outputs.run_build_artifacts }}
run_checks_fast: ${{ steps.manifest.outputs.run_checks_fast }}
checks_fast_core_matrix: ${{ steps.manifest.outputs.checks_fast_core_matrix }}
@@ -108,8 +109,16 @@ jobs:
run: |
node --input-type=module <<'EOF'
import { appendFileSync } from "node:fs";
import { listChangedExtensionIds } from "./scripts/lib/changed-extensions.mjs";
import {
listChangedExtensionIds,
listChangedPathsForScope,
} from "./scripts/lib/changed-extensions.mjs";
const changedPaths = listChangedPathsForScope({
base: process.env.BASE_SHA,
head: "HEAD",
fallbackBaseRef: process.env.BASE_REF,
});
const extensionIds = listChangedExtensionIds({
base: process.env.BASE_SHA,
head: "HEAD",
@@ -117,9 +126,11 @@ jobs:
unavailableBaseBehavior: "all",
});
const matrix = JSON.stringify({ include: extensionIds.map((extension) => ({ extension })) });
const changedPathsJson = JSON.stringify(changedPaths);
appendFileSync(process.env.GITHUB_OUTPUT, `has_changed_extensions=${extensionIds.length > 0}\n`, "utf8");
appendFileSync(process.env.GITHUB_OUTPUT, `changed_extensions_matrix=${matrix}\n`, "utf8");
appendFileSync(process.env.GITHUB_OUTPUT, `changed_paths_json=${changedPathsJson}\n`, "utf8");
EOF
- name: Build CI manifest
@@ -135,6 +146,7 @@ jobs:
OPENCLAW_CI_RUN_CONTROL_UI_I18N: ${{ steps.changed_scope.outputs.run_control_ui_i18n || 'false' }}
OPENCLAW_CI_HAS_CHANGED_EXTENSIONS: ${{ steps.changed_extensions.outputs.has_changed_extensions || 'false' }}
OPENCLAW_CI_CHANGED_EXTENSIONS_MATRIX: ${{ steps.changed_extensions.outputs.changed_extensions_matrix || '{"include":[]}' }}
OPENCLAW_CI_CHANGED_PATHS_JSON: ${{ steps.changed_extensions.outputs.changed_paths_json || '[]' }}
run: |
node --input-type=module <<'EOF'
import { appendFileSync } from "node:fs";
@@ -203,6 +215,7 @@ jobs:
run_windows: runWindows,
has_changed_extensions: hasChangedExtensions,
changed_extensions_matrix: changedExtensionsMatrix,
changed_paths_json: process.env.OPENCLAW_CI_CHANGED_PATHS_JSON ?? "[]",
run_build_artifacts: runNode,
run_checks_fast: runNode,
checks_fast_core_matrix: createMatrix(
@@ -956,6 +969,8 @@ jobs:
continue-on-error: true
env:
OPENCLAW_EXTENSION_BOUNDARY_CONCURRENCY: 4
OPENCLAW_EXTENSION_BOUNDARY_CHANGED_EXTENSIONS_MATRIX: ${{ needs.preflight.outputs.changed_extensions_matrix }}
OPENCLAW_EXTENSION_BOUNDARY_CHANGED_PATHS_JSON: ${{ needs.preflight.outputs.changed_paths_json }}
run: pnpm run test:extensions:package-boundary
- name: Enforce safe external URL opening policy

View File

@@ -28,6 +28,33 @@ const COMPILE_INPUT_EXTENSIONS = new Set([".ts", ".tsx", ".mts", ".cts", ".js",
const ROOTDIR_BOUNDARY_CANARY_IMPORT_PATH =
"../../src/plugins/contracts/rootdir-boundary-canary.ts";
const ROOTDIR_BOUNDARY_CANARY_OUTPUT_HINT = "src/plugins/contracts/rootdir-boundary-canary.ts";
const BOUNDARY_FULL_SWEEP_PATHS = new Set([
"package.json",
"pnpm-lock.yaml",
"scripts/check-extension-package-tsc-boundary.mjs",
"scripts/prepare-extension-package-boundary-artifacts.mjs",
"scripts/write-plugin-sdk-entry-dts.ts",
"scripts/lib/plugin-sdk-entries.mjs",
"scripts/lib/plugin-sdk-entrypoints.json",
"src/plugins/contracts/rootdir-boundary-canary.ts",
"src/video-generation/dashscope-compatible.ts",
"src/video-generation/types.ts",
"tsconfig.json",
"tsconfig.package-boundary.base.json",
"tsconfig.plugin-sdk.dts.json",
]);
const BOUNDARY_FULL_SWEEP_PREFIXES = [
"packages/plugin-sdk/",
"src/channels/plugins/",
"src/plugin-sdk/",
"src/types/",
];
function normalizeRepoPath(filePath) {
return String(filePath ?? "")
.replaceAll("\\", "/")
.replace(/^\.\/+/, "");
}
function parseMode(argv) {
const modeArg = argv.find((arg) => arg.startsWith("--mode="));
@@ -86,6 +113,9 @@ export function formatBoundaryCheckSuccessSummary(params = {}) {
if (params.mode) {
lines.push(`mode: ${params.mode}`);
}
if (params.scope) {
lines.push(`scope: ${params.scope}`);
}
if (Number.isInteger(params.compileCount)) {
lines.push(`compiled plugins: ${params.compileCount}`);
}
@@ -195,6 +225,97 @@ function collectCanaryExtensionIds(extensionIds) {
];
}
function isBoundaryFullSweepPath(filePath) {
const normalizedPath = normalizeRepoPath(filePath);
return (
BOUNDARY_FULL_SWEEP_PATHS.has(normalizedPath) ||
BOUNDARY_FULL_SWEEP_PREFIXES.some((prefix) => normalizedPath.startsWith(prefix))
);
}
function parseChangedExtensionsMatrix(rawMatrix) {
if (!rawMatrix || !rawMatrix.trim()) {
return [];
}
try {
const parsed = JSON.parse(rawMatrix);
if (!Array.isArray(parsed?.include)) {
return [];
}
return [
...new Set(
parsed.include
.map((entry) => (typeof entry?.extension === "string" ? entry.extension : ""))
.filter(Boolean),
),
].toSorted((left, right) => left.localeCompare(right));
} catch {
return [];
}
}
function parseChangedPathsJson(rawPaths) {
if (!rawPaths || !rawPaths.trim()) {
return null;
}
try {
const parsed = JSON.parse(rawPaths);
if (!Array.isArray(parsed)) {
return null;
}
return [...new Set(parsed.map((entry) => normalizeRepoPath(entry)).filter(Boolean))].toSorted();
} catch {
return null;
}
}
export function resolveBoundaryCheckSelection(params = {}) {
const optInExtensionIds = Array.isArray(params.optInExtensionIds)
? [...params.optInExtensionIds]
: [];
const changedPaths = params.changedPaths;
const changedExtensionIds = Array.isArray(params.changedExtensionIds)
? [...params.changedExtensionIds]
: [];
const resolveCanaryIds = params.resolveCanaryExtensionIds ?? collectCanaryExtensionIds;
if (!Array.isArray(changedPaths) || changedPaths.length === 0) {
return {
scope: "full",
compileExtensionIds: optInExtensionIds,
canaryExtensionIds: resolveCanaryIds(optInExtensionIds),
};
}
if (changedPaths.some((filePath) => isBoundaryFullSweepPath(filePath))) {
return {
scope: "full",
compileExtensionIds: optInExtensionIds,
canaryExtensionIds: resolveCanaryIds(optInExtensionIds),
};
}
const optInExtensionIdSet = new Set(optInExtensionIds);
const scopedExtensionIds = changedExtensionIds
.filter((extensionId) => optInExtensionIdSet.has(extensionId))
.toSorted((left, right) => left.localeCompare(right));
if (scopedExtensionIds.length === 0) {
return {
scope: "skip",
compileExtensionIds: [],
canaryExtensionIds: [],
};
}
return {
scope: "scoped",
compileExtensionIds: scopedExtensionIds,
canaryExtensionIds: resolveCanaryIds(scopedExtensionIds),
};
}
function isRelevantCompileInput(filePath) {
const basename = path.basename(filePath);
if (
@@ -768,7 +889,15 @@ export async function main(argv = process.argv.slice(2)) {
const startedAt = Date.now();
const mode = parseMode(argv);
const optInExtensionIds = collectOptInExtensionIds();
const canaryExtensionIds = collectCanaryExtensionIds(optInExtensionIds);
const selection = resolveBoundaryCheckSelection({
optInExtensionIds,
changedExtensionIds: parseChangedExtensionsMatrix(
process.env.OPENCLAW_EXTENSION_BOUNDARY_CHANGED_EXTENSIONS_MATRIX,
),
changedPaths: parseChangedPathsJson(process.env.OPENCLAW_EXTENSION_BOUNDARY_CHANGED_PATHS_JSON),
});
const compileExtensionIds = selection.compileExtensionIds;
const canaryExtensionIds = selection.canaryExtensionIds;
const cleanupExtensionIds = optInExtensionIds;
const shouldRunCanary = mode === "all" || mode === "canary";
const releaseBoundaryLock = acquireBoundaryCheckLock();
@@ -782,16 +911,17 @@ export async function main(argv = process.argv.slice(2)) {
try {
cleanupCanaryArtifactsForExtensions(cleanupExtensionIds);
if (mode === "all" || mode === "compile") {
if ((mode === "all" || mode === "compile") && compileExtensionIds.length > 0) {
({ prepElapsedMs, compileCount, skippedCompileCount, compileElapsedMs, compileTimings } =
await runCompileCheck(optInExtensionIds));
await runCompileCheck(compileExtensionIds));
}
if (shouldRunCanary) {
if (shouldRunCanary && canaryExtensionIds.length > 0) {
({ canaryElapsedMs } = await runCanaryCheck(canaryExtensionIds));
}
process.stdout.write(
formatBoundaryCheckSuccessSummary({
mode,
scope: selection.scope,
compileCount,
skippedCompileCount,
canaryCount: shouldRunCanary ? canaryExtensionIds.length : 0,

View File

@@ -12,7 +12,7 @@ import { resolveBuildRequirement } from "./run-node.mjs";
const DEFAULTS = {
outputDir: path.join(process.cwd(), ".local", "gateway-watch-regression"),
windowMs: 10_000,
readyTimeoutMs: 20_000,
readyTimeoutMs: 5_000,
readySettleMs: 500,
sigkillGraceMs: 10_000,
cpuWarnMs: 1_000,
@@ -34,6 +34,8 @@ const WATCH_GATEWAY_SKIP_ENV = {
OPENCLAW_SKIP_GMAIL_WATCHER: "1",
};
const ANSI_ESCAPE_PATTERN = new RegExp(String.raw`\u001B\[[0-9;]*m`, "g");
function parseArgs(argv) {
const options = { ...DEFAULTS };
for (let i = 0; i < argv.length; i += 1) {
@@ -96,9 +98,54 @@ function normalizePath(filePath) {
return filePath.replaceAll("\\", "/");
}
function listTreeEntries(rootName) {
const rootPath = path.join(process.cwd(), rootName);
if (!fs.existsSync(rootPath)) {
function isMissingPathError(error) {
return (
Boolean(error) &&
typeof error === "object" &&
"code" in error &&
(error.code === "ENOENT" || error.code === "ENOTDIR")
);
}
function safeReaddirSync(fsImpl, dirPath) {
try {
return fsImpl.readdirSync(dirPath, { withFileTypes: true });
} catch (error) {
if (isMissingPathError(error)) {
return [];
}
throw error;
}
}
function safeLstatSync(fsImpl, filePath) {
try {
return fsImpl.lstatSync(filePath);
} catch (error) {
if (isMissingPathError(error)) {
return null;
}
throw error;
}
}
export function stripAnsi(text) {
return String(text ?? "").replaceAll(ANSI_ESCAPE_PATTERN, "");
}
export function hasGatewayReadyLog(text) {
return stripAnsi(text).includes("[gateway] ready (");
}
export function resolveReadyObservation(readyBeforeWindow, stdout, stderr) {
return readyBeforeWindow || hasGatewayReadyLog(stdout) || hasGatewayReadyLog(stderr);
}
export function listTreeEntries(rootName, params = {}) {
const fsImpl = params.fs ?? fs;
const cwd = params.cwd ?? process.cwd();
const rootPath = path.join(cwd, rootName);
if (!fsImpl.existsSync(rootPath)) {
return [`${rootName} (missing)`];
}
@@ -109,10 +156,10 @@ function listTreeEntries(rootName) {
if (!current) {
continue;
}
const dirents = fs.readdirSync(current, { withFileTypes: true });
const dirents = safeReaddirSync(fsImpl, current);
for (const dirent of dirents) {
const fullPath = path.join(current, dirent.name);
const relativePath = normalizePath(path.relative(process.cwd(), fullPath));
const relativePath = normalizePath(path.relative(cwd, fullPath));
entries.push(relativePath);
if (dirent.isDirectory()) {
queue.push(fullPath);
@@ -135,10 +182,12 @@ function humanBytes(bytes) {
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)}G`;
}
function snapshotTree(rootName) {
const rootPath = path.join(process.cwd(), rootName);
export function snapshotTree(rootName, params = {}) {
const fsImpl = params.fs ?? fs;
const cwd = params.cwd ?? process.cwd();
const rootPath = path.join(cwd, rootName);
const stats = {
exists: fs.existsSync(rootPath),
exists: fsImpl.existsSync(rootPath),
files: 0,
directories: 0,
symlinks: 0,
@@ -156,11 +205,14 @@ function snapshotTree(rootName) {
if (!current) {
continue;
}
const currentStats = fs.lstatSync(current);
const currentStats = safeLstatSync(fsImpl, current);
if (!currentStats) {
continue;
}
stats.entries += 1;
if (currentStats.isDirectory()) {
stats.directories += 1;
for (const dirent of fs.readdirSync(current, { withFileTypes: true })) {
for (const dirent of safeReaddirSync(fsImpl, current)) {
queue.push(path.join(current, dirent.name));
}
continue;
@@ -312,13 +364,15 @@ function readProcessTreeCpuMs(rootPid) {
async function waitForGatewayReady(readText, timeoutMs) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (/\[gateway\] ready \(/.test(readText())) {
while (true) {
if (hasGatewayReadyLog(readText())) {
return true;
}
if (Date.now() >= deadline) {
return false;
}
await sleep(100);
}
return false;
}
async function allocateLoopbackPort() {
@@ -435,7 +489,7 @@ async function runTimedWatch(options, outputDir) {
});
const exitPromise = new Promise((resolve) => {
child.on("exit", (code, signal) => resolve({ code, signal }));
child.on("close", (code, signal) => resolve({ code, signal }));
});
let watchPid = null;
@@ -447,7 +501,7 @@ async function runTimedWatch(options, outputDir) {
await sleep(100);
}
const readyBeforeWindow = await waitForGatewayReady(
let readyBeforeWindow = await waitForGatewayReady(
() => `${stdout}\n${stderr}`,
options.readyTimeoutMs,
);
@@ -482,6 +536,7 @@ async function runTimedWatch(options, outputDir) {
}
const exit = (await exitPromise) ?? { code: null, signal: null };
readyBeforeWindow = resolveReadyObservation(readyBeforeWindow, stdout, stderr);
fs.writeFileSync(stdoutPath, stdout, "utf8");
fs.writeFileSync(stderrPath, stderr, "utf8");
const timing = fs.existsSync(timeFilePath)
@@ -559,11 +614,13 @@ function buildRunNodeDeps(env) {
};
}
async function main() {
export async function main() {
const options = parseArgs(process.argv.slice(2));
ensureDir(options.outputDir);
if (!options.skipBuild) {
runCheckedCommand("pnpm", ["build"]);
// This regression only needs runtime dist artifacts; plugin-sdk d.ts output
// adds CI time without affecting watch startup behavior.
runCheckedCommand("pnpm", ["build:ci-artifacts"]);
// The watch harness must start from a completed-build baseline. Refresh
// the build stamp after the full build pipeline finishes so run-node does
// not spuriously rebuild inside the bounded watch window.
@@ -697,4 +754,6 @@ async function main() {
process.exit(0);
}
await main();
if (import.meta.main) {
await main();
}

View File

@@ -70,6 +70,11 @@ function listChangedPaths(base, head = "HEAD") {
.filter((line) => line.length > 0);
}
export function listChangedPathsForScope(params = {}) {
const head = params.head ?? "HEAD";
return listChangedPaths(resolveChangedPathsBase(params), head);
}
function hasExtensionPackage(extensionId) {
return fs.existsSync(path.join(repoRoot, BUNDLED_PLUGIN_ROOT_DIR, extensionId, "package.json"));
}
@@ -122,8 +127,7 @@ export function listChangedExtensionIds(params = {}) {
const unavailableBaseBehavior = params.unavailableBaseBehavior ?? "error";
try {
const base = resolveChangedPathsBase(params);
return detectChangedExtensionIds(listChangedPaths(base, head));
return detectChangedExtensionIds(listChangedPathsForScope({ ...params, head }));
} catch (error) {
if (unavailableBaseBehavior === "all") {
return listAvailableExtensionIds();

View File

@@ -12,6 +12,7 @@ import {
formatStepFailure,
installCanaryArtifactCleanup,
isBoundaryCompileFresh,
resolveBoundaryCheckSelection,
resolveBoundaryCheckLockPath,
resolveCanaryArtifactPaths,
runNodeStepAsync,
@@ -165,6 +166,29 @@ describe("check-extension-package-tsc-boundary", () => {
);
});
it("adds the scope label when the boundary check was CI-scoped", () => {
expect(
formatBoundaryCheckSuccessSummary({
mode: "all",
scope: "scoped",
compileCount: 2,
skippedCompileCount: 0,
canaryCount: 1,
elapsedMs: 1_234,
}),
).toBe(
[
"extension package boundary check passed",
"mode: all",
"scope: scoped",
"compiled plugins: 2",
"canary plugins: 1",
"elapsed: 1234ms",
"",
].join("\n"),
);
});
it("omits phase timings that never ran", () => {
expect(
formatBoundaryCheckSuccessSummary({
@@ -220,6 +244,50 @@ describe("check-extension-package-tsc-boundary", () => {
).toBe(["slowest plugin compiles:", "- slow: 900ms", "- medium: 250ms", ""].join("\n"));
});
it("keeps the full sweep when shared plugin-sdk paths changed", () => {
expect(
resolveBoundaryCheckSelection({
optInExtensionIds: ["browser", "matrix", "zalo"],
changedExtensionIds: ["browser"],
changedPaths: ["src/plugin-sdk/provider-entry.ts"],
resolveCanaryExtensionIds: (extensionIds: string[]) => extensionIds.slice(0, 2),
}),
).toEqual({
scope: "full",
compileExtensionIds: ["browser", "matrix", "zalo"],
canaryExtensionIds: ["browser", "matrix"],
});
});
it("scopes CI compiles to changed opt-in extensions when only extension-local paths changed", () => {
expect(
resolveBoundaryCheckSelection({
optInExtensionIds: ["browser", "matrix", "zalo"],
changedExtensionIds: ["browser", "matrix"],
changedPaths: ["extensions/browser/src/runtime.ts", "docs/plugins/sdk-overview.md"],
resolveCanaryExtensionIds: (extensionIds: string[]) => extensionIds,
}),
).toEqual({
scope: "scoped",
compileExtensionIds: ["browser", "matrix"],
canaryExtensionIds: ["browser", "matrix"],
});
});
it("skips the boundary sweep when no opt-in extension or shared path changed", () => {
expect(
resolveBoundaryCheckSelection({
optInExtensionIds: ["browser", "matrix", "zalo"],
changedExtensionIds: ["docs-helper"],
changedPaths: ["docs/reference/cli.md"],
}),
).toEqual({
scope: "skip",
compileExtensionIds: [],
canaryExtensionIds: [],
});
});
it("treats a plugin compile as fresh only when its outputs are newer than plugin and shared sdk inputs", () => {
const { rootDir, extensionRoot } = createTempExtensionRoot();
const extensionSourcePath = path.join(extensionRoot, "index.ts");

View File

@@ -0,0 +1,91 @@
import { describe, expect, it } from "vitest";
import {
hasGatewayReadyLog,
listTreeEntries,
resolveReadyObservation,
snapshotTree,
stripAnsi,
} from "../../scripts/check-gateway-watch-regression.mjs";
function createDirent(name: string, kind: "dir" | "file" | "symlink") {
return {
name,
isDirectory: () => kind === "dir",
isFile: () => kind === "file",
isSymbolicLink: () => kind === "symlink",
};
}
describe("check-gateway-watch-regression", () => {
it("detects the gateway ready line even when logs are ANSI-colorized", () => {
const line =
"\u001b[90m2026-04-17T16:47:21.723+00:00\u001b[39m \u001b[36m[gateway]\u001b[39m \u001b[36mready (5 plugins: acpx; 1.8s)\u001b[39m";
expect(stripAnsi(line)).toContain("[gateway] ready (5 plugins: acpx; 1.8s)");
expect(hasGatewayReadyLog(line)).toBe(true);
});
it("treats a buffered ready log as a successful ready observation", () => {
expect(
resolveReadyObservation(
false,
"2026-04-17T10:34:39.684-07:00 [gateway] ready (5 plugins: acpx; 3.9s)\n",
"",
),
).toBe(true);
});
it("keeps missing trees explicit in snapshot path listings", () => {
const entries = listTreeEntries("dist-runtime", {
cwd: "/repo",
fs: {
existsSync: () => false,
},
});
expect(entries).toEqual(["dist-runtime (missing)"]);
});
it("ignores files that disappear between readdir and lstat while snapshotting", () => {
const fakeFs = {
existsSync(filePath: string) {
return filePath === "/repo/dist";
},
readdirSync(filePath: string) {
if (filePath === "/repo/dist") {
return [createDirent("kept.js", "file"), createDirent("gone.js", "file")];
}
return [];
},
lstatSync(filePath: string) {
if (filePath === "/repo/dist") {
return {
isDirectory: () => true,
isFile: () => false,
isSymbolicLink: () => false,
};
}
if (filePath === "/repo/dist/kept.js") {
return {
size: 7,
isDirectory: () => false,
isFile: () => true,
isSymbolicLink: () => false,
};
}
const error = new Error(`ENOENT: ${filePath}`) as NodeJS.ErrnoException;
error.code = "ENOENT";
throw error;
},
};
expect(snapshotTree("dist", { cwd: "/repo", fs: fakeFs })).toEqual({
exists: true,
files: 1,
directories: 1,
symlinks: 0,
entries: 2,
apparentBytes: 7,
});
});
});