mirror of
https://github.com/openclaw/openclaw.git
synced 2026-11-01 04:58:23 +00:00
* improve(ui): keep delegated work inline with its originating turn Co-authored-by: steipete <58493+steipete@users.noreply.github.com> * improve(ui): keep delegated work inline with its originating turn Worked on by: - @steipete Co-authored-by: steipete <58493+steipete@users.noreply.github.com> OpenClaw-Publication: 31748fca-ccd7-4111-bbf0-87f237b132ad * build(ui): regenerate validators with merged dependencies Co-authored-by: steipete <58493+steipete@users.noreply.github.com> * fix(ui): complete inline delegation CI integration Align the English catalog and wrapper import inventory, keep CSS within the existing budget and cursor policy, and update history/layout fixtures for inline delegation. Co-authored-by: steipete <58493+steipete@users.noreply.github.com> --------- Co-authored-by: steipete <58493+steipete@users.noreply.github.com>
398 lines
14 KiB
TypeScript
398 lines
14 KiB
TypeScript
import { readFileSync } from "node:fs";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { build } from "esbuild";
|
|
import { format } from "oxfmt";
|
|
import { IsLiteralString, ObjectOptions, Type, type TSchema } from "typebox";
|
|
import * as Compiler from "typebox/schema";
|
|
import { AgentRuntimeRestrictionErrorDetailsSchema } from "../packages/gateway-protocol/src/agent-runtime-restriction-error-details.js";
|
|
import {
|
|
SessionApprovalEventSchema,
|
|
SessionApprovalReplaySchema,
|
|
} from "../packages/gateway-protocol/src/schema/approvals.js";
|
|
import { BoardSnapshotSchema } from "../packages/gateway-protocol/src/schema/board.js";
|
|
import { CommandsListResultSchema } from "../packages/gateway-protocol/src/schema/commands.js";
|
|
import {
|
|
AgentActivityItemSchema,
|
|
AgentActivityDelegationSchema,
|
|
ChatHistoryParamsSchema,
|
|
ChatPendingInputsPageSchema,
|
|
} from "../packages/gateway-protocol/src/schema/logs-chat.js";
|
|
import { ProgressCardSchema } from "../packages/gateway-protocol/src/schema/progress-card.js";
|
|
import { SessionParticipantIdentitySchema } from "../packages/gateway-protocol/src/schema/session-participant.js";
|
|
import { SessionsCreateParamsSchema } from "../packages/gateway-protocol/src/schema/sessions-create.js";
|
|
import {
|
|
SessionsGoalClearParamsSchema,
|
|
SessionsGoalUpdateParamsSchema,
|
|
} from "../packages/gateway-protocol/src/schema/sessions-goal.js";
|
|
import { SessionReactionEventSchema } from "../packages/gateway-protocol/src/schema/sessions-reactions.js";
|
|
import {
|
|
SessionPermissionModeSchema,
|
|
SessionRowSchema,
|
|
SessionToolOverridesSchema,
|
|
} from "../packages/gateway-protocol/src/schema/sessions-row.js";
|
|
import {
|
|
SessionNarrationEventSchema,
|
|
SessionObserverDigestSchema,
|
|
} from "../packages/gateway-protocol/src/schema/sessions.js";
|
|
import { TalkVoiceChangeEventSchema } from "../packages/gateway-protocol/src/schema/talk-voice.js";
|
|
import { CHAT_TRANSCRIPT_METADATA_KEYS } from "../ui/src/pages/chat/chat-transcript-metadata.ts";
|
|
import { writeGeneratedOutput } from "./lib/generated-output-utils.mts";
|
|
|
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
const typeboxBuildRoot = path.dirname(fileURLToPath(import.meta.resolve("typebox")));
|
|
const outputBase = "ui/src/lib/protocol-validators.generated";
|
|
const header = "// Generated by pnpm ui:validators:gen; do not edit.\n";
|
|
const schemaRoot = "../../../../packages/gateway-protocol/src/schema/";
|
|
type Group =
|
|
| "activity"
|
|
| "approvals"
|
|
| "session-input"
|
|
| "transcript"
|
|
| "board"
|
|
| "history-state"
|
|
| "sender-identity";
|
|
const compilerLintRules = [
|
|
"eslint/no-unused-vars",
|
|
"eslint/prefer-const",
|
|
"eslint/no-unused-expressions",
|
|
"eslint/no-implicit-coercion",
|
|
"eslint/no-var",
|
|
];
|
|
const additionalLintRules: Partial<Record<Group, string[]>> = {
|
|
activity: ["eslint/curly"],
|
|
approvals: ["unicorn/no-useless-length-check"],
|
|
};
|
|
const validators: Array<[name: string, schema: TSchema, type: string, group: Group]> = [
|
|
[
|
|
"BoardSnapshot",
|
|
BoardSnapshotSchema,
|
|
'import("' + schemaRoot + 'board.js").BoardSnapshot',
|
|
"board",
|
|
],
|
|
[
|
|
"SessionParticipantIdentity",
|
|
SessionParticipantIdentitySchema,
|
|
'import("../schema/session-participant.js").SessionParticipantIdentity',
|
|
"sender-identity",
|
|
],
|
|
[
|
|
"AgentActivityItem",
|
|
AgentActivityItemSchema,
|
|
'import("' + schemaRoot + 'logs-chat.js").AgentActivityItem',
|
|
"activity",
|
|
],
|
|
[
|
|
"AgentRuntimeRestrictionErrorDetails",
|
|
AgentRuntimeRestrictionErrorDetailsSchema,
|
|
'import("../../../../packages/gateway-protocol/src/agent-runtime-restriction-error-details.js").AgentRuntimeRestrictionErrorDetails',
|
|
"session-input",
|
|
],
|
|
[
|
|
"SessionObserverDigest",
|
|
SessionObserverDigestSchema,
|
|
'import("' + schemaRoot + 'sessions.js").SessionObserverDigest',
|
|
"activity",
|
|
],
|
|
[
|
|
"SessionNarrationEvent",
|
|
SessionNarrationEventSchema,
|
|
'import("' + schemaRoot + 'sessions.js").SessionNarrationEvent',
|
|
"activity",
|
|
],
|
|
[
|
|
"SessionReactionEvent",
|
|
SessionReactionEventSchema,
|
|
'import("' + schemaRoot + 'sessions-reactions.js").SessionReactionEvent',
|
|
"activity",
|
|
],
|
|
[
|
|
"SessionApprovalEvent",
|
|
SessionApprovalEventSchema,
|
|
'import("' + schemaRoot + 'approvals.js").SessionApprovalEvent',
|
|
"approvals",
|
|
],
|
|
[
|
|
"SessionApprovalReplay",
|
|
SessionApprovalReplaySchema,
|
|
'import("' + schemaRoot + 'approvals.js").SessionApprovalReplay',
|
|
"approvals",
|
|
],
|
|
[
|
|
"SessionsGoalUpdateParams",
|
|
SessionsGoalUpdateParamsSchema,
|
|
'import("' + schemaRoot + 'sessions-goal.js").SessionsGoalUpdateParams',
|
|
"session-input",
|
|
],
|
|
[
|
|
"SessionsGoalClearParams",
|
|
SessionsGoalClearParamsSchema,
|
|
'import("' + schemaRoot + 'sessions-goal.js").SessionsGoalClearParams',
|
|
"session-input",
|
|
],
|
|
...(["worktreeSource", "deviceFolder", "repository", "fastMode", "titleSource"] as const).map(
|
|
(key): [string, TSchema, string, Group] => [
|
|
`Session${key.charAt(0).toUpperCase()}${key.slice(1)}`,
|
|
SessionsCreateParamsSchema.properties[key],
|
|
`import("${schemaRoot}sessions-create.js").SessionsCreateParams["${key}"]`,
|
|
"session-input",
|
|
],
|
|
),
|
|
[
|
|
"SessionPermissionMode",
|
|
SessionPermissionModeSchema,
|
|
'import("' + schemaRoot + 'sessions-row.js").SessionPermissionMode',
|
|
"session-input",
|
|
],
|
|
[
|
|
"SessionToolOverrides",
|
|
SessionToolOverridesSchema,
|
|
'import("' + schemaRoot + 'sessions-row.js").SessionToolOverrides',
|
|
"session-input",
|
|
],
|
|
[
|
|
"ChatHistoryState",
|
|
Type.Object(
|
|
{
|
|
stateRevision: Type.Required(ChatHistoryParamsSchema).properties.knownState,
|
|
pendingInputs: Type.Optional(ChatPendingInputsPageSchema),
|
|
sessionInfo: Type.Optional(SessionRowSchema),
|
|
commands: Type.Optional(CommandsListResultSchema.properties.commands),
|
|
},
|
|
{ additionalProperties: false },
|
|
),
|
|
'import("../../pages/chat/chat-transcript-metadata.ts").ChatHistoryState',
|
|
"history-state",
|
|
],
|
|
[
|
|
"ChatTranscriptMetadata",
|
|
Type.Pick(SessionRowSchema, CHAT_TRANSCRIPT_METADATA_KEYS),
|
|
'import("../../pages/chat/chat-transcript-metadata.ts").ChatTranscriptMetadata',
|
|
"transcript",
|
|
],
|
|
[
|
|
"ProgressCard",
|
|
ProgressCardSchema,
|
|
'import("' + schemaRoot + 'progress-card.js").ProgressCard',
|
|
"transcript",
|
|
],
|
|
[
|
|
"TalkVoiceChangeEvent",
|
|
TalkVoiceChangeEventSchema,
|
|
'import("' + schemaRoot + 'talk-voice.js").TalkVoiceChangeEvent',
|
|
"session-input",
|
|
],
|
|
];
|
|
|
|
function emitStandalone(
|
|
binding: string,
|
|
functions: string,
|
|
variables: unknown[],
|
|
entry: string,
|
|
): string {
|
|
const supportedHelpers = new Set(["Guard.CodePointCount", "Hashing.Hash"]);
|
|
for (const helper of functions.match(/(?:Guard|Hashing)\.\w+/g) ?? []) {
|
|
if (!supportedHelpers.has(helper)) {
|
|
throw new Error(`${binding}: unsupported standalone helper ${helper}`);
|
|
}
|
|
}
|
|
const external = variables.map((value) => {
|
|
if (!(value instanceof RegExp)) {
|
|
throw new Error(`${binding}: unsupported external value`);
|
|
}
|
|
return `new RegExp(${JSON.stringify(value.source)}, ${JSON.stringify(value.flags)})`;
|
|
});
|
|
return `export const ${binding} = /* @__PURE__ */ (() => {
|
|
const External = [${external.join(",")}];
|
|
${functions};
|
|
return ${entry};
|
|
})();`;
|
|
}
|
|
|
|
function compile(name: string, schema: TSchema): string {
|
|
const compiled = Compiler.Build(schema);
|
|
if (compiled.UseUnevaluated()) {
|
|
throw new Error(`${name}: unevaluated schemas need an explicit standalone implementation`);
|
|
}
|
|
return emitStandalone(
|
|
`is${name}`,
|
|
compiled.Functions().join(";\n"),
|
|
compiled.External().variables,
|
|
`value => ${compiled.Entry()}`,
|
|
);
|
|
}
|
|
|
|
function onlyStringUniqueItems(value: unknown): boolean {
|
|
if (!value || typeof value !== "object") {
|
|
return true;
|
|
}
|
|
if (
|
|
"uniqueItems" in value &&
|
|
value.uniqueItems === true &&
|
|
!(Type.IsArray(value) && Type.IsUnion(value.items) && value.items.anyOf.every(IsLiteralString))
|
|
) {
|
|
return false;
|
|
}
|
|
return Object.values(value).every(onlyStringUniqueItems);
|
|
}
|
|
|
|
function compileShared(entries: typeof validators): string {
|
|
const sharedSchema = Compiler.Intern(
|
|
Type.Object(Object.fromEntries(entries.map(([name, schema]) => [name, schema]))),
|
|
);
|
|
const root = sharedSchema.$defs[sharedSchema.$ref.slice("#/$defs/".length)];
|
|
if (!root || !Compiler.IsProperties(root) || Compiler.HasUnevaluated({}, sharedSchema)) {
|
|
throw new Error("Shared validators need a standalone object schema without unevaluated checks");
|
|
}
|
|
// Match Build's reset boundary while sharing one scope across the group entry points.
|
|
Compiler.ResetExternal();
|
|
Compiler.ResetFunctions();
|
|
const context = new Compiler.BuildContext(false);
|
|
const stack = Compiler.Stack({}, sharedSchema);
|
|
const entriesSource = Object.entries(root.properties).map(
|
|
([name, entry]) =>
|
|
`is${name}: value => ${Compiler.CreateFunction(stack, context, entry, "value")}`,
|
|
);
|
|
let functions = Compiler.GetFunctions().join(";\n");
|
|
if (entries.every(([, schema]) => onlyStringUniqueItems(schema))) {
|
|
// These finite string enums have the same equality under Set and TypeBox hashing.
|
|
functions = functions.replaceAll(".map(Hashing.Hash)", "");
|
|
}
|
|
return emitStandalone(
|
|
`{ ${entries.map(([name]) => `is${name}`).join(", ")} }`,
|
|
functions,
|
|
Compiler.GetExternal().variables,
|
|
`{ ${entriesSource.join(", ")} }`,
|
|
);
|
|
}
|
|
|
|
async function main() {
|
|
const args = process.argv.slice(2);
|
|
if (args.some((arg) => arg !== "--check")) {
|
|
throw new Error("Usage: pnpm ui:validators:gen [--check]");
|
|
}
|
|
// Activity receipts are the only nested record; keep their cleaner schema-derived too.
|
|
for (const schema of [AgentActivityItemSchema, AgentActivityDelegationSchema]) {
|
|
if (ObjectOptions(schema).additionalProperties !== false) {
|
|
throw new Error("Activity cleaners require closed records");
|
|
}
|
|
}
|
|
const cleaner = `const activityKeys = new Set(${JSON.stringify(Object.keys(AgentActivityItemSchema.properties))});
|
|
const delegationKeys = new Set(${JSON.stringify(Object.keys(AgentActivityDelegationSchema.properties))});
|
|
function cleanRecord(value, keys) {
|
|
if (typeof value !== "object" || value === null || Array.isArray(value)) return value;
|
|
for (const key of Object.getOwnPropertyNames(value)) {
|
|
if (!keys.has(key)) delete value[key];
|
|
}
|
|
return value;
|
|
}
|
|
export function cleanAgentActivityItem(value) {
|
|
cleanRecord(value, activityKeys);
|
|
if (value && typeof value === "object" && Array.isArray(value.delegation)) {
|
|
for (const receipt of value.delegation) cleanRecord(receipt, delegationKeys);
|
|
}
|
|
return value;
|
|
}`;
|
|
const licensePath = path.resolve(fileURLToPath(import.meta.resolve("typebox")), "../../license");
|
|
const license = `/*!\n${readFileSync(licensePath, "utf8").trim()}\n*/\n`;
|
|
for (const group of [
|
|
"activity",
|
|
"approvals",
|
|
"session-input",
|
|
"transcript",
|
|
"history-state",
|
|
"sender-identity",
|
|
"board",
|
|
] as const) {
|
|
const selected = validators.filter((entry) => entry[3] === group);
|
|
// Share repeated shapes only within each existing lazy boundary.
|
|
const compiled = ["activity", "approvals", "session-input"].includes(group)
|
|
? compileShared(selected)
|
|
: selected
|
|
.map(([name, schema]) =>
|
|
compile(
|
|
name,
|
|
name === "ChatHistoryState" || name === "ChatTranscriptMetadata"
|
|
? Compiler.Intern(schema)
|
|
: schema,
|
|
),
|
|
)
|
|
.join("\n");
|
|
// Import the compiler's exact helpers by name so one scalar check does not bundle every guard.
|
|
const source = [
|
|
...(compiled.includes("Guard.")
|
|
? [
|
|
`import { CodePointCount } from ${JSON.stringify(path.join(typeboxBuildRoot, "guard/unicode/unicode.mjs"))};`,
|
|
]
|
|
: []),
|
|
...(compiled.includes("Hashing.")
|
|
? [
|
|
`import { Hash } from ${JSON.stringify(path.join(typeboxBuildRoot, "system/hashing/hash.mjs"))};`,
|
|
]
|
|
: []),
|
|
compiled
|
|
.replaceAll("Guard.CodePointCount", "CodePointCount")
|
|
.replaceAll("Hashing.Hash", "Hash"),
|
|
...(group === "activity" ? [cleaner] : []),
|
|
].join("\n");
|
|
const result = await build({
|
|
stdin: { contents: source, resolveDir: repoRoot, loader: "js" },
|
|
bundle: true,
|
|
format: "esm",
|
|
platform: "browser",
|
|
target: "es2022",
|
|
write: false,
|
|
minifySyntax: true,
|
|
// Remove esbuild's physical input-path banners before canonical formatting.
|
|
// The TypeBox license is retained explicitly below.
|
|
minifyWhitespace: true,
|
|
legalComments: "none",
|
|
});
|
|
const outputFile = result.outputFiles[0];
|
|
if (!outputFile) {
|
|
throw new Error(`${group}: standalone validator build emitted no output`);
|
|
}
|
|
const runtime = outputFile.text;
|
|
if (/\b(?:eval|Function|require|import)\s*\(/.test(runtime) || /^import\s/m.test(runtime)) {
|
|
throw new Error("Generated validators must be standalone and CSP-safe");
|
|
}
|
|
const declaration = selected
|
|
.map(([name, , type]) => `export function is${name}(value: unknown): value is ${type};`)
|
|
.concat(
|
|
group === "activity"
|
|
? ["export function cleanAgentActivityItem(value: unknown): unknown;"]
|
|
: [],
|
|
)
|
|
.join("\n");
|
|
for (const [extension, contents] of [
|
|
["js", license + runtime],
|
|
["d.ts", declaration],
|
|
]) {
|
|
const outputPath =
|
|
group === "sender-identity"
|
|
? `packages/gateway-protocol/src/compiled/sender-identity.${extension}`
|
|
: `${outputBase}/${group}.${extension}`;
|
|
const generatedHeader =
|
|
extension === "js"
|
|
? header +
|
|
`/* oxlint-disable ${[...compilerLintRules, ...(additionalLintRules[group] ?? [])].join(", ")} -- Generated TypeBox validators; canonical schema parity is tested. */\n`
|
|
: header;
|
|
const formatted = await format(outputPath, generatedHeader + contents);
|
|
if (formatted.errors.length) {
|
|
throw new Error(`Cannot format ${outputPath}: ${JSON.stringify(formatted.errors)}`);
|
|
}
|
|
const output = writeGeneratedOutput({
|
|
check: args.includes("--check"),
|
|
next: formatted.code,
|
|
outputPath,
|
|
repoRoot,
|
|
});
|
|
if (args.includes("--check") && output.changed) {
|
|
throw new Error(`${outputPath} is stale; run pnpm ui:validators:gen`);
|
|
}
|
|
console.log(`[ui:validators] ${output.changed ? "updated" : "unchanged"} ${outputPath}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
await main();
|