openclaw/scripts/check-protocol-since.mts
RoboClaw b28c930d15
chore: update dependencies with a seven-day cooldown (#176423)
* chore: update dependencies with a seven-day cooldown

Worked on by:
- @steipete

Co-authored-by: steipete <58493+steipete@users.noreply.github.com>
OpenClaw-Publication: 85123e9c-a543-48e3-a29c-9b01ea5ef83f

* Publish openclaw/update-all-dependencies-with-a-seven-day-cool-off

Worked on by:
- @steipete

Co-authored-by: steipete <58493+steipete@users.noreply.github.com>
OpenClaw-Publication: 74b57fb5-59b0-4d2f-8884-b509c249838e

* fix: repair dependency qualification gates

Join pending plugin-state writes during native guard preparation. Update ingress and cancellation fixtures at their actual owning boundaries and provision the Watch runtime before screenshots.

Co-authored-by: steipete <58493+steipete@users.noreply.github.com>

* fix: qualify dependency update runtime and tooling paths

Join database-wide pending publication before preparing native authority; retain execution settlement independently of terminal presentation. Defer interaction-only chat dialogs, exercise the actual formatter wrapper, and provision usable Watch runtimes without weakening gates.

Co-authored-by: steipete <58493+steipete@users.noreply.github.com>

* Publish openclaw/update-all-dependencies-with-a-seven-day-cool-off

Worked on by:
- @steipete

Co-authored-by: steipete <58493+steipete@users.noreply.github.com>
OpenClaw-Publication: 8642734d-2859-4f3c-a70e-7a4d2f5a1ea1

* Publish openclaw/update-all-dependencies-with-a-seven-day-cool-off

Worked on by:
- @steipete

Co-authored-by: steipete <58493+steipete@users.noreply.github.com>
OpenClaw-Publication: 6147753b-a480-4d04-8882-6d9b51006f47

* fix: prepare monitor authority and verify Watch runtime readiness

Rejoin plugin-state publication after awaited Codex monitor preparation while preserving the original synchronous authority guard. Require Watch runtime bootability before accepting cached simulator metadata.

Co-authored-by: steipete <58493+steipete@users.noreply.github.com>

* Publish openclaw/update-all-dependencies-with-a-seven-day-cool-off

Worked on by:
- @steipete

Co-authored-by: steipete <58493+steipete@users.noreply.github.com>
OpenClaw-Publication: 02d702e3-4030-496e-abc5-c818d4aec7a0

* Publish openclaw/update-all-dependencies-with-a-seven-day-cool-off

Worked on by:
- @steipete

Co-authored-by: steipete <58493+steipete@users.noreply.github.com>
OpenClaw-Publication: 3e069942-c5c9-4059-9fe0-bae78e34b6e6

* test: await lazy attachment previews in consumer fixtures

Preserve attachment behavior assertions while waiting for their async preview boundary. Control the cold read until its pending state is visible. Extract preview cases from the over-cap chat-view test without removing coverage. Include the canonical root-help mock-export repair from e63269c28a.

Co-authored-by: steipete <58493+steipete@users.noreply.github.com>

* Publish openclaw/update-all-dependencies-with-a-seven-day-cool-off

Worked on by:
- @steipete

Co-authored-by: steipete <58493+steipete@users.noreply.github.com>
OpenClaw-Publication: d8af6943-60b3-445b-8fa5-536dfd300cc8

* test(codex): isolate deferred hooks and handshake clocks

Co-authored-by: steipete <58493+steipete@users.noreply.github.com>

* Publish openclaw/update-all-dependencies-with-a-seven-day-cool-off

Worked on by:
- @steipete

Co-authored-by: steipete <58493+steipete@users.noreply.github.com>
OpenClaw-Publication: 18e051ba-09cc-4fa2-87b7-49eda74ac61b

* test(ci): keep the capacity-plan timings mock export-preserving

(cherry picked from commit b791dd0664)

* test(ui): align model defaults help expectations

---------

Co-authored-by: steipete <58493+steipete@users.noreply.github.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-10-19 04:47:07 -07:00

236 lines
8.4 KiB
TypeScript

#!/usr/bin/env node
import { spawnSync } from "node:child_process";
// Enforces release-train metadata on core gateway methods.
import fs from "node:fs";
import path from "node:path";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import * as ts from "typescript/ast";
import { createNativeTypeScriptParser } from "./lib/native-typescript.mts";
import { resolveRepoRoot } from "./lib/repo-root.mjs";
import { getPropertyNameText } from "./lib/ts-guard-utils.mts";
const repoRoot = resolveRepoRoot(import.meta.url);
const descriptorPath = "src/gateway/methods/core-descriptors.ts";
type MethodSpec = {
line: number;
name: string;
since: string | undefined;
compatibilityRestored: boolean;
};
function runGit(args: string[]): string {
const result = spawnSync("git", args, { cwd: repoRoot, encoding: "utf8" });
if (result.status !== 0) {
const detail = result.stderr.trim() || result.stdout.trim() || `git exited ${result.status}`;
throw new Error(
`${detail}\nRun git fetch origin main so origin/main and its merge-base are available.`,
);
}
return result.stdout.trim();
}
function tryRunGit(args: string[]): string | undefined {
const result = spawnSync("git", args, { cwd: repoRoot, encoding: "utf8" });
return result.status === 0 ? result.stdout.trim() : undefined;
}
function resolveBaseCommit(): string {
const explicitBase = process.env.PROTOCOL_SINCE_BASE_SHA?.trim();
if (explicitBase) {
if (!/^[0-9a-f]{40}$/u.test(explicitBase)) {
throw new Error("PROTOCOL_SINCE_BASE_SHA must be a full commit SHA.");
}
if (tryRunGit(["cat-file", "-e", `${explicitBase}:${descriptorPath}`]) === undefined) {
throw new Error(`PROTOCOL_SINCE_BASE_SHA ${explicitBase} is unavailable in this checkout.`);
}
return explicitBase;
}
const mainMergeBase = tryRunGit(["merge-base", "HEAD", "origin/main"]);
if (mainMergeBase) {
return mainMergeBase;
}
// Pull-request CI checks out a synthetic merge commit without creating origin/main.
// Its first parent is the exact base used to build the merge result.
const parents = (tryRunGit(["show", "-s", "--format=%P", "HEAD"]) ?? "").split(/\s+/u);
if (parents.length === 2 && parents[0]) {
return parents[0];
}
throw new Error(
"Could not resolve PROTOCOL_SINCE_BASE_SHA, origin/main, or a two-parent pull-request merge checkout.",
);
}
function unwrapExpression(expression: ts.Expression): ts.Expression {
let current = expression;
while (
ts.isAsExpression(current) ||
ts.isSatisfiesExpression(current) ||
ts.isParenthesizedExpression(current)
) {
current = current.expression;
}
return current;
}
function stringProperty(object: ts.ObjectLiteralExpression, key: string): string | undefined {
for (const property of object.properties) {
if (!ts.isPropertyAssignment(property)) {
continue;
}
const name = getPropertyNameText(property.name);
if (name === key && ts.isStringLiteralLikeNode(property.initializer)) {
return property.initializer.text;
}
}
return undefined;
}
function trueProperty(object: ts.ObjectLiteralExpression, key: string): boolean {
return object.properties.some((property) => {
if (!ts.isPropertyAssignment(property)) {
return false;
}
const name = getPropertyNameText(property.name);
return name === key && property.initializer.kind === ts.SyntaxKind.TrueKeyword;
});
}
function collectMethodSpec(
element: ts.Expression,
sourceFile: ts.SourceFile,
fileName: string,
): MethodSpec {
const line = sourceFile.getLineAndCharacterOfPosition(element.getStart(sourceFile)).line + 1;
if (ts.isObjectLiteralExpression(element)) {
const name = stringProperty(element, "name");
if (!name) {
throw new Error(
`${fileName}:${line} core method spec names must be string literals so additions can be compared with origin/main.`,
);
}
return {
name,
since: stringProperty(element, "since"),
compatibilityRestored: trueProperty(element, "compatibilityRestored"),
line,
};
}
if (ts.isArrayLiteralExpression(element)) {
const name = element.elements[0];
const since = element.elements[3];
if (
!name ||
!since ||
!ts.isStringLiteralLikeNode(name) ||
!ts.isStringLiteralLikeNode(since)
) {
throw new Error(
`${fileName}:${line} core method spec rows must use string literal names and vintage metadata.`,
);
}
const policy = element.elements[4];
const compatibilityRestored =
policy !== undefined && ts.isObjectLiteralExpression(policy)
? trueProperty(policy, "compatibilityRestored")
: false;
return { name: name.text, since: since.text, compatibilityRestored, line };
}
throw new Error(
`${fileName}:${line} core method specs must be inline object literals or labeled rows so vintage metadata can be enforced.`,
);
}
function collectMethodSpecs(sourceFile: ts.SourceFile, fileName: string): MethodSpec[] {
let specs: MethodSpec[] | undefined;
function visit(node: ts.Node): void {
if (
ts.isVariableDeclaration(node) &&
ts.isIdentifier(node.name) &&
node.name.text === "CORE_GATEWAY_METHOD_SPECS" &&
node.initializer
) {
const initializer = unwrapExpression(node.initializer);
if (ts.isArrayLiteralExpression(initializer)) {
specs = initializer.elements.map((element) =>
collectMethodSpec(element, sourceFile, fileName),
);
}
}
node.forEachChild(visit);
}
visit(sourceFile);
if (!specs) {
throw new Error(`Could not find CORE_GATEWAY_METHOD_SPECS in ${fileName}.`);
}
return specs;
}
function currentTrain(): string {
const packageJson: unknown = JSON.parse(
fs.readFileSync(path.join(repoRoot, "package.json"), "utf8"),
);
if (!isRecord(packageJson)) {
throw new Error("Root package.json must contain an object.");
}
const version = typeof packageJson.version === "string" ? packageJson.version : "";
const match = /^(\d{4})\.(\d{1,2})(?:\.|$)/.exec(version);
if (!match) {
throw new Error(`Root package version ${JSON.stringify(packageJson.version)} is not calver.`);
}
return `${match[1]!}.${match[2]!}`;
}
try {
using parser = createNativeTypeScriptParser({ cwd: repoRoot });
const train = currentTrain();
const mergeBase = resolveBaseCommit();
const currentSource = fs.readFileSync(path.join(repoRoot, descriptorPath), "utf8");
const currentSpecs = collectMethodSpecs(
parser.parseSourceFile(descriptorPath, currentSource),
descriptorPath,
);
const baseSource = runGit(["show", `${mergeBase}:${descriptorPath}`]);
const baseNames = new Set(
collectMethodSpecs(
parser.parseSourceFile(descriptorPath, baseSource),
`${descriptorPath}@${mergeBase}`,
).map((s) => s.name),
);
const added = currentSpecs.filter((spec) => !baseNames.has(spec.name));
const restored = added.filter((spec) => spec.compatibilityRestored);
const newMethods = added.filter((spec) => !spec.compatibilityRestored);
// Restored shipped methods retain their historical vintage so discovery and
// generated clients see the original availability contract, not a new API.
const violations = added.filter((spec) =>
spec.compatibilityRestored ? !spec.since?.startsWith("<=") : spec.since !== train,
);
if (violations.length > 0) {
console.error(`Protocol since guard failed for current train ${train}:`);
for (const spec of violations) {
const problem = spec.compatibilityRestored
? `is marked compatibilityRestored but has non-historical since ${JSON.stringify(spec.since)}`
: spec.since
? `has since ${JSON.stringify(spec.since)}`
: "is missing since metadata";
console.error(
spec.compatibilityRestored
? `- ${descriptorPath}:${spec.line} ${spec.name} ${problem}; restored compatibility methods must retain <= vintage metadata.`
: `- ${descriptorPath}:${spec.line} ${spec.name} ${problem}; add since: ${JSON.stringify(train)}.`,
);
}
process.exitCode = 1;
} else {
console.log(
`protocol since guard passed: ${newMethods.length} new core method${newMethods.length === 1 ? "" : "s"} use train ${train}; ${restored.length} restored compatibility method${restored.length === 1 ? "" : "s"} retain historical vintage`,
);
}
} catch (error) {
console.error(
`Protocol since guard failed: ${error instanceof Error ? error.message : String(error)}`,
);
process.exitCode = 1;
}