refactor: canonicalize binary operation results (#106049)

* refactor(normalization-core): promote shared result type

* refactor(agent-core): reuse canonical error coercion

* refactor: discriminate internal operation results

* docs: establish shared result convention

* fix(agent-core): preserve error coercion facade

* build: derive package entries from exports

* fix(plugins): resolve shared result subpath

* fix(ci): align Result integration guards

* build: refresh plugin SDK API baseline

* fix(agent-core): preserve toError compatibility
This commit is contained in:
Peter Steinberger
2026-07-13 00:39:43 -07:00
committed by GitHub
parent 2fc91410e8
commit 6df498c3cd
35 changed files with 186 additions and 222 deletions
+1
View File
@@ -68,6 +68,7 @@ Skills own workflows; root owns hard policy and routing.
- Config/env surface bar is high; `openclaw.json` and environment variables are already large. Before adding a config option or env var, first prove existing product behavior, provider selection, defaults, or doctor migration cannot solve it. Prefer removing or consolidating config/env options when touching these surfaces. Core supports only the latest config shape; `openclaw doctor --fix` migrates older shipped shapes into the current one.
- CLI setup flows are public API when external docs, installers, or integrations can copy them. Changes to `openclaw onboard`, `openclaw configure`, their documented flags, non-interactive behavior, or generated config shape are compatibility-sensitive API contract changes; prefer additive flags/aliases, deprecation windows, and backward-preserving migrations over breaking existing snippets.
- Fix shape: default to clean bounded refactor, not smallest patch. Move ownership to right boundary; delete stale abstractions, duplicate policy, dead branches, wrappers, fallback stacks.
- New binary fallible-operation results use `Result` from `@openclaw/normalization-core/result`; domain-rich outcomes keep named discriminated unions.
- Fix observed local failures with generic product rules; do not hardcode names, ids, log phrases, or user examples in prod code unless they are an explicit contract.
- Tests may use observed examples, but prod literals need a short contract reason.
- Compatibility is opt-in. "Shipped" means reachable from a release Git tag; main/GitHub/PR/unreleased code is not shipped.
@@ -1,2 +1,2 @@
21f63180d1606d450af8d2509c2e12ae7bcb0e92b77429ee35b5d350d4f9a427 plugin-sdk-api-baseline.json
7ea611a65949cbd82c54a63f3fc1818d88bf6b6515fcb53ef59a911b2ca4a822 plugin-sdk-api-baseline.jsonl
83cfad05d155e019b7bf2f3278eaa2dca6e59d391f124d8bf5bfc838e870d8dd plugin-sdk-api-baseline.json
275ad4b635e76fa209cb8d2d99d7de4225f154306cfb2cb0b0c36c17730e756e plugin-sdk-api-baseline.jsonl
@@ -1,4 +1,5 @@
// Agent Core module implements agent harness behavior.
import { toErrorObject } from "@openclaw/normalization-core/error-coercion";
import type {
AssistantMessage,
ImageContent,
@@ -52,13 +53,7 @@ import type {
Session,
Skill,
} from "./types.js";
import {
AgentHarnessError,
BranchSummaryError,
CompactionError,
SessionError,
toError,
} from "./types.js";
import { AgentHarnessError, BranchSummaryError, CompactionError, SessionError } from "./types.js";
// CoreAgentHarness coordinates session state, resources, tools, compaction, and
// streaming callbacks around the lower-level agent loop.
@@ -164,7 +159,7 @@ function normalizeHarnessError(
if (error instanceof AgentHarnessError) {
return error;
}
const cause = toError(error);
const cause = toErrorObject(error, "Non-Error thrown");
if (cause instanceof SessionError) {
return new AgentHarnessError("session", cause.message, cause);
}
@@ -672,7 +667,7 @@ export class CoreAgentHarness<
);
} catch (failureError) {
const cause = new AggregateError(
[toError(error), toError(failureError)],
[error, failureError].map((value) => toErrorObject(value, "Non-Error thrown")),
"Agent run failed and failure reporting failed",
);
throw new AgentHarnessError("unknown", cause.message, cause);
@@ -1130,17 +1125,17 @@ export class CoreAgentHarness<
try {
await this.emitQueueUpdate();
} catch (error) {
errors.push(toError(error));
errors.push(toErrorObject(error, "Non-Error thrown"));
}
try {
await this.waitForIdle();
} catch (error) {
errors.push(toError(error));
errors.push(toErrorObject(error, "Non-Error thrown"));
}
try {
await this.emitOwn({ type: "abort", clearedSteer, clearedFollowUp });
} catch (error) {
errors.push(toError(error));
errors.push(toErrorObject(error, "Non-Error thrown"));
}
if (errors.length > 0) {
const cause =
+5 -5
View File
@@ -17,6 +17,7 @@ import {
import { tmpdir } from "node:os";
import { basename, isAbsolute, join, resolve } from "node:path";
import { createInterface } from "node:readline";
import { toErrorObject } from "@openclaw/normalization-core/error-coercion";
import {
type ExecutionEnv,
ExecutionError,
@@ -26,7 +27,6 @@ import {
type FileKind,
ok,
type Result,
toError,
} from "../types.js";
import { killProcessTree } from "./kill-tree.js";
@@ -100,7 +100,7 @@ function toFileError(error: unknown, path?: string): FileError {
if (error instanceof FileError) {
return error;
}
const cause = toError(error);
const cause = toErrorObject(error, "Non-Error thrown");
if (isNodeError(error)) {
const message = error.message;
switch (error.code) {
@@ -342,7 +342,7 @@ export class NodeExecutionEnv implements ExecutionEnv {
windowsHide: true,
});
} catch (error) {
const cause = toError(error);
const cause = toErrorObject(error, "Non-Error thrown");
settle(err(new ExecutionError("spawn_error", cause.message, cause)));
return;
}
@@ -373,7 +373,7 @@ export class NodeExecutionEnv implements ExecutionEnv {
try {
options?.onStdout?.(chunk);
} catch (error) {
const cause = toError(error);
const cause = toErrorObject(error, "Non-Error thrown");
callbackError = new ExecutionError("callback_error", cause.message, cause);
onAbort();
}
@@ -383,7 +383,7 @@ export class NodeExecutionEnv implements ExecutionEnv {
try {
options?.onStderr?.(chunk);
} catch (error) {
const cause = toError(error);
const cause = toErrorObject(error, "Non-Error thrown");
callbackError = new ExecutionError("callback_error", cause.message, cause);
onAbort();
}
@@ -1,4 +1,5 @@
// Agent Core module implements jsonl storage behavior.
import { toErrorObject } from "@openclaw/normalization-core/error-coercion";
import type {
FileError,
FileSystem,
@@ -6,7 +7,7 @@ import type {
Result,
SessionTreeEntry,
} from "../types.js";
import { SessionError, toError } from "../types.js";
import { SessionError } from "../types.js";
import {
appendParentIdAfterEntry,
BaseSessionStorage,
@@ -69,7 +70,11 @@ function parseHeaderLine(line: string, filePath: string): SessionHeader {
try {
parsed = JSON.parse(line);
} catch (error) {
throw invalidSession(filePath, "first line is not a valid session header", toError(error));
throw invalidSession(
filePath,
"first line is not a valid session header",
toErrorObject(error, "Non-Error thrown"),
);
}
if (!isRecord(parsed)) {
throw invalidSession(filePath, "first line is not a valid session header");
@@ -110,7 +115,12 @@ function parseEntryLine(line: string, filePath: string, lineNumber: number): Ses
try {
parsed = JSON.parse(line);
} catch (error) {
throw invalidEntry(filePath, lineNumber, "is not valid JSON", toError(error));
throw invalidEntry(
filePath,
lineNumber,
"is not valid JSON",
toErrorObject(error, "Non-Error thrown"),
);
}
if (!isRecord(parsed)) {
throw invalidEntry(filePath, lineNumber, "is not a valid session entry");
@@ -0,0 +1,13 @@
import { describe, expect, it } from "vitest";
import { toError } from "./types.js";
describe("toError", () => {
it("preserves the shipped facade semantics", () => {
const thrown = { code: "E_TEST", detail: "structured" };
const error = toError(thrown);
expect(error.message).toBe('{"code":"E_TEST","detail":"structured"}');
expect(error).not.toHaveProperty("cause");
});
});
+7 -13
View File
@@ -1,4 +1,5 @@
// Agent Core type module defines shared TypeScript contracts.
import type { Result } from "@openclaw/normalization-core/result";
import type {
ImageContent,
Model,
@@ -11,20 +12,13 @@ import type { AgentEvent, AgentMessage, AgentTool, QueueMode, ThinkingLevel } fr
import type { AgentCoreCompletionRuntimeDeps, AgentCoreRuntimeDeps } from "../runtime-deps.js";
import type { Session } from "./session/session.js";
/** Result of a fallible operation. Expected failures are returned as `ok: false` instead of thrown. */
export type Result<TValue, TError> = { ok: true; value: TValue } | { ok: false; error: TError };
export { err, ok } from "@openclaw/normalization-core/result";
export type { Result } from "@openclaw/normalization-core/result";
/** Create a successful {@link Result}. */
export function ok<TValue, TError>(value: TValue): Result<TValue, TError> {
return { ok: true, value };
}
/** Create a failed {@link Result}. */
export function err<TValue, TError>(error: TError): Result<TValue, TError> {
return { ok: false, error };
}
/** Normalize unknown thrown values into Error instances before using them as typed error causes. */
/**
* @deprecated Use `toErrorObject` from `@openclaw/normalization-core/error-coercion`.
* Kept through the next major release for the shipped agent-core plugin API.
*/
export function toError(error: unknown): Error {
if (error instanceof Error) {
return error;
+6 -1
View File
@@ -39,6 +39,11 @@
"import": "./dist/record-coerce.mjs",
"default": "./dist/record-coerce.mjs"
},
"./result": {
"types": "./dist/result.d.mts",
"import": "./dist/result.mjs",
"default": "./dist/result.mjs"
},
"./string-coerce": {
"types": "./dist/string-coerce.d.mts",
"import": "./dist/string-coerce.mjs",
@@ -56,6 +61,6 @@
}
},
"scripts": {
"build": "tsdown src/index.ts src/boolean-coercion.ts src/error-coercion.ts src/expect.ts src/number-coercion.ts src/record-coerce.ts src/string-coerce.ts src/string-normalization.ts src/utf16-slice.ts --no-config --platform node --format esm --dts --out-dir dist --clean"
"build": "tsdown src/index.ts src/boolean-coercion.ts src/error-coercion.ts src/expect.ts src/number-coercion.ts src/record-coerce.ts src/result.ts src/string-coerce.ts src/string-normalization.ts src/utf16-slice.ts --no-config --platform node --format esm --dts --out-dir dist --clean"
}
}
@@ -0,0 +1,9 @@
import { describe, expect, it } from "vitest";
import { err, ok } from "./result.js";
describe("Result constructors", () => {
it("creates discriminated success and failure arms", () => {
expect(ok("value")).toEqual({ ok: true, value: "value" });
expect(err("failure")).toEqual({ ok: false, error: "failure" });
});
});
+12
View File
@@ -0,0 +1,12 @@
/** Result of a fallible operation. Expected failures use the `ok: false` arm. */
export type Result<TValue, TError> = { ok: true; value: TValue } | { ok: false; error: TError };
/** Create a successful {@link Result}. */
export function ok<TValue, TError>(value: TValue): Result<TValue, TError> {
return { ok: true, value };
}
/** Create a failed {@link Result}. */
export function err<TValue, TError>(error: TError): Result<TValue, TError> {
return { ok: false, error };
}
+2 -6
View File
@@ -11,6 +11,7 @@ import {
resolveExpiresAtMsFromDurationSeconds,
} from "@openclaw/normalization-core/number-coercion";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import type { Result } from "@openclaw/normalization-core/result";
import { uniqueValues } from "@openclaw/normalization-core/string-normalization";
import { Type } from "typebox";
import type { OpenClawConfig } from "../config/types.openclaw.js";
@@ -97,12 +98,7 @@ type PendingBridgeRequest = {
args: unknown[];
};
type SettledBridgeRequest = {
id: string;
ok: boolean;
value?: unknown;
error?: string;
};
type SettledBridgeRequest = { id: string } & Result<unknown, string>;
type PendingBridgeState = PendingBridgeRequest & {
promise: Promise<SettledBridgeRequest>;
+2 -6
View File
@@ -6,6 +6,7 @@ import { readFile } from "node:fs/promises";
import { createRequire } from "node:module";
import { parentPort, workerData } from "node:worker_threads";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import type { Result } from "@openclaw/normalization-core/result";
import { EvalFlags, Intrinsics, JSException, QuickJS, type JSValueHandle } from "quickjs-wasi";
import type { CodeModeApiVirtualFile } from "./code-mode-namespaces.js";
const require = createRequire(import.meta.url);
@@ -27,12 +28,7 @@ type PendingBridgeRequest = {
args: unknown[];
};
type SettledBridgeRequest = {
id: string;
ok: boolean;
value?: unknown;
error?: string;
};
type SettledBridgeRequest = { id: string } & Result<unknown, string>;
type SerializedCodeModeNamespaceValue =
| { kind: "array"; items: SerializedCodeModeNamespaceValue[] }
+2 -7
View File
@@ -6,6 +6,7 @@
import { spawn } from "node:child_process";
import os from "node:os";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import type { Result } from "@openclaw/normalization-core/result";
import {
normalizeStringEntries,
uniqueStrings,
@@ -161,13 +162,7 @@ type CodeModeChildMessage =
| { type: "log"; items?: unknown[] }
| { type: "bridge"; id?: unknown; method?: unknown; args?: unknown };
type CodeModeBridgeResultMessage = {
type: "bridge-result";
id: string;
ok: boolean;
value?: unknown;
error?: string;
};
type CodeModeBridgeResultMessage = { type: "bridge-result"; id: string } & Result<unknown, string>;
const TOOL_SEARCH_CODE_MODE_CHILD_SOURCE = String.raw`
import vm from "node:vm";
+12 -17
View File
@@ -91,10 +91,10 @@ export const handleConfigCommand: CommandHandler = async (params, allowTextComma
return missingAdminScope;
}
const parsedPath = parseConfigPath(configCommand.path);
if (!parsedPath.ok || !parsedPath.path) {
if (!parsedPath.ok) {
return {
shouldContinue: false,
reply: { text: `⚠️ ${parsedPath.error ?? "Invalid path."}` },
reply: { text: `⚠️ ${parsedPath.error}` },
};
}
parsedWritePath = parsedPath.path;
@@ -138,10 +138,10 @@ export const handleConfigCommand: CommandHandler = async (params, allowTextComma
const pathRaw = normalizeOptionalString(configCommand.path);
if (pathRaw) {
const parsedPath = parseConfigPath(pathRaw);
if (!parsedPath.ok || !parsedPath.path) {
if (!parsedPath.ok) {
return {
shouldContinue: false,
reply: { text: `⚠️ ${parsedPath.error ?? "Invalid path."}` },
reply: { text: `⚠️ ${parsedPath.error}` },
};
}
const value = getConfigValueAtPath(parsedBase, parsedPath.path);
@@ -270,10 +270,10 @@ export const handleDebugCommand: CommandHandler = async (params, allowTextComman
if (!result.ok) {
return {
shouldContinue: false,
reply: { text: `⚠️ ${result.error ?? "Invalid path."}` },
reply: { text: `⚠️ ${result.error}` },
};
}
if (!result.removed) {
if (!result.value) {
return {
shouldContinue: false,
reply: {
@@ -291,19 +291,14 @@ export const handleDebugCommand: CommandHandler = async (params, allowTextComman
if (!result.ok) {
return {
shouldContinue: false,
reply: { text: `⚠️ ${result.error ?? "Invalid override."}` },
reply: { text: `⚠️ ${result.error}` },
};
}
const parsedOverridePath = parseConfigPath(debugCommand.path);
const valueLabel = parsedOverridePath.path
? formatConfigSetValueLabel({
path: parsedOverridePath.path,
value: debugCommand.value,
uiHints: loadGatewayRuntimeConfigSchema().uiHints,
})
: typeof debugCommand.value === "string"
? `"${debugCommand.value}"`
: JSON.stringify(debugCommand.value);
const valueLabel = formatConfigSetValueLabel({
path: result.value,
value: debugCommand.value,
uiHints: loadGatewayRuntimeConfigSchema().uiHints,
});
return {
shouldContinue: false,
reply: {
+2 -2
View File
@@ -127,8 +127,8 @@ vi.mock("../../config/config.js", () => ({
vi.mock("../../config/runtime-overrides.js", () => ({
getConfigOverrides: getConfigOverridesMock,
resetConfigOverrides: vi.fn(),
setConfigOverride: vi.fn(() => ({ ok: true })),
unsetConfigOverride: vi.fn(() => ({ ok: true, removed: true })),
setConfigOverride: vi.fn((pathRaw: string) => ({ ok: true, value: pathRaw.split(".") })),
unsetConfigOverride: vi.fn(() => ({ ok: true, value: true })),
}));
vi.mock("../../config/runtime-schema.js", async () => {
+1 -1
View File
@@ -581,7 +581,7 @@ export async function gatherDaemonStatus(
.catch((err: unknown) => ({ status: "unknown", detail: String(err) })),
]);
const restartHandoff = opts.deep ? readGatewayRestartHandoffSync(serviceEnv) : null;
const configAudit = command
const configAudit: ServiceConfigAudit = command
? await loadServiceAuditModule().then(({ auditGatewayServiceConfig }) =>
auditGatewayServiceConfig({
env: process.env,
+1 -1
View File
@@ -1254,7 +1254,7 @@ describe("config paths", () => {
it("sets, gets, and unsets nested values", () => {
const root: Record<string, unknown> = {};
const parsed = parseConfigPath("foo.bar");
if (!parsed.ok || !parsed.path) {
if (!parsed.ok) {
throw new Error("path parse failed");
}
setConfigValueAtPath(root, parsed.path, 123);
+3 -5
View File
@@ -19,11 +19,9 @@ function setOwnConfigProperty(node: PathNode, key: string, value: unknown): void
}
/** Parses CLI/config dot-notation paths and rejects unsafe object-key segments. */
export function parseConfigPath(raw: string): {
ok: boolean;
path?: string[];
error?: string;
} {
export function parseConfigPath(
raw: string,
): { ok: true; path: string[] } | { ok: false; error: string } {
const trimmed = raw.trim();
if (!trimmed) {
return {
+1 -2
View File
@@ -46,8 +46,7 @@ describe("runtime overrides", () => {
it("unsets overrides and prunes empty branches", () => {
setConfigOverride("channels.whatsapp.dmPolicy", "open");
const removed = unsetConfigOverride("channels.whatsapp.dmPolicy");
expect(removed.ok).toBe(true);
expect(removed.removed).toBe(true);
expect(removed).toEqual({ ok: true, value: true });
expect(Object.keys(getConfigOverrides()).length).toBe(0);
});
+9 -22
View File
@@ -1,3 +1,4 @@
import { err, ok, type Result } from "@openclaw/normalization-core/result";
import { isBlockedObjectKey } from "../infra/prototype-keys.js";
// Applies runtime-only config overrides without mutating persisted config.
import { isPlainObject } from "../utils.js";
@@ -56,37 +57,23 @@ export function resetConfigOverrides(): void {
}
/** Set one runtime override at a parsed config path after sanitizing object values. */
export function setConfigOverride(
pathRaw: string,
value: unknown,
): {
ok: boolean;
error?: string;
} {
export function setConfigOverride(pathRaw: string, value: unknown): Result<string[], string> {
const parsed = parseConfigPath(pathRaw);
if (!parsed.ok || !parsed.path) {
return { ok: false, error: parsed.error ?? "Invalid path." };
if (!parsed.ok) {
return err(parsed.error);
}
setConfigValueAtPath(overrides, parsed.path, sanitizeOverrideValue(value));
return { ok: true };
return ok(parsed.path);
}
/** Remove one runtime override path and report whether an override was present. */
export function unsetConfigOverride(pathRaw: string): {
ok: boolean;
removed: boolean;
error?: string;
} {
export function unsetConfigOverride(pathRaw: string): Result<boolean, string> {
const parsed = parseConfigPath(pathRaw);
if (!parsed.ok || !parsed.path) {
return {
ok: false,
removed: false,
error: parsed.error ?? "Invalid path.",
};
if (!parsed.ok) {
return err(parsed.error);
}
const removed = unsetConfigValueAtPath(overrides, parsed.path);
return { ok: true, removed };
return ok(removed);
}
/** Merge the current runtime overrides over a loaded config without mutating the input config. */
+1 -1
View File
@@ -53,7 +53,7 @@ describe("scheduleDetachedLaunchdRestartHandoff", () => {
waitForPid: 9876,
});
expect(result).toEqual({ ok: true, pid: 4242 });
expect(result).toEqual({ ok: true, value: 4242 });
expect(spawnMock).toHaveBeenCalledTimes(1);
const [, args] = requireSpawnCall();
expect(args[0]).toBe("-c");
+5 -11
View File
@@ -2,6 +2,7 @@
import { spawn } from "node:child_process";
import os from "node:os";
import path from "node:path";
import { err, ok, type Result } from "@openclaw/normalization-core/result";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { sanitizeForLog } from "../../packages/terminal-core/src/ansi.js";
import { formatErrorMessage } from "../infra/errors.js";
@@ -12,11 +13,7 @@ import { renderPosixRestartLogSetup } from "./restart-logs.js";
type LaunchdRestartHandoffMode = "kickstart" | "reload" | "start-after-exit";
type LaunchdRestartHandoffResult = {
ok: boolean;
pid?: number;
detail?: string;
};
type LaunchdRestartHandoffResult = Result<number | undefined, string>;
type LaunchdRestartTarget = {
domain: string;
@@ -249,11 +246,8 @@ export function scheduleDetachedLaunchdRestartHandoff(params: {
},
);
child.unref();
return { ok: true, pid: child.pid ?? undefined };
} catch (err) {
return {
ok: false,
detail: formatErrorMessage(err),
};
return ok(child.pid ?? undefined);
} catch (error) {
return err(formatErrorMessage(error));
}
}
+4 -4
View File
@@ -57,8 +57,8 @@ const state = vi.hoisted(() => ({
}));
const launchdRestartHandoffState = vi.hoisted(() => ({
scheduleDetachedLaunchdRestartHandoff: vi.fn<
(_params: unknown) => { ok: boolean; pid?: number; detail?: string }
>(() => ({ ok: true, pid: 7331 })),
(_params: unknown) => { ok: true; value: number | undefined } | { ok: false; error: string }
>(() => ({ ok: true, value: 7331 })),
}));
const cleanStaleGatewayProcessesSync = vi.hoisted(() =>
vi.fn<(port?: number) => number[]>(() => []),
@@ -422,7 +422,7 @@ beforeEach(() => {
launchdRestartHandoffState.scheduleDetachedLaunchdRestartHandoff.mockReset();
launchdRestartHandoffState.scheduleDetachedLaunchdRestartHandoff.mockReturnValue({
ok: true,
pid: 7331,
value: 7331,
});
vi.clearAllMocks();
});
@@ -2301,7 +2301,7 @@ describe("launchd install", () => {
const env = createDefaultLaunchdEnv();
launchdRestartHandoffState.scheduleDetachedLaunchdRestartHandoff.mockReturnValue({
ok: false,
detail: "spawn failed",
error: "spawn failed",
});
await expect(
+1 -1
View File
@@ -1286,7 +1286,7 @@ export async function restartLaunchAgent({
waitForPid: process.pid,
});
if (!handoff.ok) {
throw new Error(`launchd restart handoff failed: ${handoff.detail ?? "unknown error"}`);
throw new Error(`launchd restart handoff failed: ${handoff.error}`);
}
writeLaunchAgentActionLine(stdout, "Scheduled LaunchAgent restart", serviceTarget);
return { outcome: "scheduled" };
+4 -6
View File
@@ -47,11 +47,9 @@ export type ServiceConfigIssue = {
level?: "recommended" | "aggressive";
};
export type ServiceConfigAudit = {
ok: boolean;
issues: ServiceConfigIssue[];
};
export type ServiceConfigAudit =
| { ok: true; issues: ServiceConfigIssue[] }
| { ok: false; issues: ServiceConfigIssue[] };
export const SERVICE_AUDIT_CODES = {
gatewayCommandMissing: "gateway-command-missing",
gatewayEntrypointMismatch: "gateway-entrypoint-mismatch",
@@ -668,5 +666,5 @@ export async function auditGatewayServiceConfig(params: {
await auditLaunchdPlist(params.env, issues);
}
return { ok: issues.length === 0, issues };
return issues.length === 0 ? { ok: true, issues } : { ok: false, issues };
}
+1
View File
@@ -96,6 +96,7 @@ const workspacePackageAliasEntries = {
srcFile: "src/record-coerce.ts",
distFile: "dist/record-coerce.mjs",
},
result: { srcFile: "src/result.ts", distFile: "dist/result.mjs" },
"string-coerce": {
srcFile: "src/string-coerce.ts",
distFile: "dist/string-coerce.mjs",
@@ -519,6 +519,7 @@ describe("plugin-sdk root alias", () => {
"src",
"number-coercion.ts",
),
result: path.join(packageRoot, "packages", "normalization-core", "src", "result.ts"),
retry: path.join(packageRoot, "packages", "retry", "src", "index.ts"),
};
const lazyModule = loadRootAliasWithStubs({
@@ -535,6 +536,7 @@ describe("plugin-sdk root alias", () => {
expect(aliasMap["@openclaw/normalization-core/number-coercion"]).toBe(
sourcePaths.numberCoercion,
);
expect(aliasMap["@openclaw/normalization-core/result"]).toBe(sourcePaths.result);
expect(aliasMap["@openclaw/retry"]).toBe(sourcePaths.retry);
});
@@ -592,6 +594,7 @@ describe("plugin-sdk root alias", () => {
"@openclaw/normalization-core/error-coercion",
"@openclaw/normalization-core/number-coercion",
"@openclaw/normalization-core/record-coerce",
"@openclaw/normalization-core/result",
"@openclaw/normalization-core/string-coerce",
"@openclaw/normalization-core/string-normalization",
"@openclaw/normalization-core/utf16-slice",
+13 -15
View File
@@ -2,6 +2,7 @@
import { createHash } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { err as resultError, ok, type Result } from "@openclaw/normalization-core/result";
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
import { clearAgentHarnesses } from "../agents/harness/registry.js";
import { resolveConfigEnvVars } from "../config/env-substitution.js";
@@ -1344,11 +1345,10 @@ function validatePluginConfig(params: {
schema?: Record<string, unknown>;
cacheKey?: string;
value?: unknown;
}): { ok: boolean; value?: Record<string, unknown>; errors?: string[] } {
const value = params.value;
const schema = params.schema;
}): Result<Record<string, unknown> | undefined, string[]> {
const { schema, value } = params;
if (!schema) {
return { ok: true, value: value as Record<string, unknown> | undefined };
return ok(value as Record<string, unknown> | undefined);
}
if (isEmptyPluginConfigJsonSchema(schema)) {
if (
@@ -1358,12 +1358,12 @@ function validatePluginConfig(params: {
!Array.isArray(value) &&
Object.keys(value).length === 0)
) {
return { ok: true, value: {} };
return ok({});
}
if (!value || typeof value !== "object" || Array.isArray(value)) {
return { ok: false, errors: ["<root>: must be object"] };
return resultError(["<root>: must be object"]);
}
return { ok: false, errors: ["<root>: config must be empty"] };
return resultError(["<root>: config must be empty"]);
}
const cacheKey = params.cacheKey ?? JSON.stringify(schema);
const result = validateJsonSchemaValue({
@@ -1373,9 +1373,9 @@ function validatePluginConfig(params: {
applyDefaults: true,
});
if (result.ok) {
return { ok: true, value: result.value as Record<string, unknown> | undefined };
return ok(result.value as Record<string, unknown> | undefined);
}
return { ok: false, errors: result.errors.map((error) => error.text) };
return resultError(result.errors.map((error) => error.text));
}
function isEmptyPluginConfigJsonSchema(schema: Record<string, unknown>): boolean {
@@ -2110,10 +2110,8 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
});
if (!validatedConfig.ok) {
logger.error(
`[plugins] ${record.id} invalid config: ${validatedConfig.errors?.join(", ")}`,
);
pushPluginLoadError(`invalid config: ${validatedConfig.errors?.join(", ")}`);
logger.error(`[plugins] ${record.id} invalid config: ${validatedConfig.error.join(", ")}`);
pushPluginLoadError(`invalid config: ${validatedConfig.error.join(", ")}`);
continue;
}
@@ -2871,8 +2869,8 @@ export async function loadOpenClawPluginCliRegistry(
value: entry?.config,
});
if (!validatedConfig.ok) {
logger.error(`[plugins] ${record.id} invalid config: ${validatedConfig.errors?.join(", ")}`);
pushPluginLoadError(`invalid config: ${validatedConfig.errors?.join(", ")}`);
logger.error(`[plugins] ${record.id} invalid config: ${validatedConfig.error.join(", ")}`);
pushPluginLoadError(`invalid config: ${validatedConfig.error.join(", ")}`);
continue;
}
@@ -389,6 +389,7 @@ describe("installOpenClawPluginSdkNativeResolver", () => {
"normalization-core",
"boolean-coercion.ts",
);
const resultSource = writeInternalCorePackageSource(root, "normalization-core", "result.ts");
const mediaCoreSource = writeInternalCorePackageSource(root, "media-core", "mime.ts");
const markdownCoreSource = writeInternalCorePackageSource(
root,
@@ -424,6 +425,7 @@ describe("installOpenClawPluginSdkNativeResolver", () => {
expect(installedAliases).toContain("@openclaw/normalization-core/string-coerce");
expect(installedAliases).toContain("@openclaw/normalization-core/boolean-coercion");
expect(installedAliases).toContain("@openclaw/normalization-core/result");
expect(installedAliases).toContain("@openclaw/media-core/mime");
expect(installedAliases).toContain("@openclaw/markdown-core/code-spans");
expect(installedAliases).toContain("@openclaw/ai/internal/retry-after");
@@ -440,6 +442,9 @@ describe("installOpenClawPluginSdkNativeResolver", () => {
requireFromCoreSource.resolve("@openclaw/normalization-core/boolean-coercion"),
),
).toBe(fs.realpathSync(booleanCoercionSource));
expect(
fs.realpathSync(requireFromCoreSource.resolve("@openclaw/normalization-core/result")),
).toBe(fs.realpathSync(resultSource));
expect(fs.realpathSync(requireFromCoreSource.resolve("@openclaw/media-core/mime"))).toBe(
fs.realpathSync(mediaCoreSource),
);
@@ -462,6 +467,7 @@ describe("installOpenClawPluginSdkNativeResolver", () => {
expect(() =>
requireFromPlugin.resolve("@openclaw/normalization-core/boolean-coercion"),
).toThrow();
expect(() => requireFromPlugin.resolve("@openclaw/normalization-core/result")).toThrow();
expect(() => requireFromPlugin.resolve("@openclaw/media-core/mime")).toThrow();
expect(() => requireFromPlugin.resolve("@openclaw/markdown-core/code-spans")).toThrow();
expect(() => requireFromPlugin.resolve("@openclaw/ai/internal/retry-after")).toThrow();
@@ -76,6 +76,7 @@ const INTERNAL_CORE_PACKAGE_ALIASES = [
["error-coercion", "error-coercion.ts"],
["number-coercion", "number-coercion.ts"],
["record-coerce", "record-coerce.ts"],
["result", "result.ts"],
["string-coerce", "string-coerce.ts"],
["string-normalization", "string-normalization.ts"],
["utf16-slice", "utf16-slice.ts"],
+10
View File
@@ -1564,6 +1564,12 @@ describe("plugin sdk alias helpers", () => {
srcFile: "boolean-coercion.ts",
distFile: "boolean-coercion.mjs",
});
const normalizationResult = writeWorkspacePackageEntry({
root: fixture.root,
packageDir: "normalization-core",
srcFile: "result.ts",
distFile: "result.mjs",
});
const normalizationStringCoerce = writeWorkspacePackageEntry({
root: fixture.root,
packageDir: "normalization-core",
@@ -1639,6 +1645,7 @@ describe("plugin sdk alias helpers", () => {
fs.rmSync(acpCoreRuntimeTypes.distFile);
fs.rmSync(normalizationCore.distFile);
fs.rmSync(normalizationBooleanCoercion.distFile);
fs.rmSync(normalizationResult.distFile);
fs.rmSync(normalizationStringCoerce.distFile);
fs.rmSync(retry.distFile);
fs.rmSync(terminalCore.distFile);
@@ -1701,6 +1708,9 @@ describe("plugin sdk alias helpers", () => {
expect(fs.realpathSync(aliases["@openclaw/normalization-core/boolean-coercion"] ?? "")).toBe(
fs.realpathSync(normalizationBooleanCoercion.srcFile),
);
expect(fs.realpathSync(aliases["@openclaw/normalization-core/result"] ?? "")).toBe(
fs.realpathSync(normalizationResult.srcFile),
);
expect(fs.realpathSync(aliases["@openclaw/normalization-core/string-coerce"] ?? "")).toBe(
fs.realpathSync(normalizationStringCoerce.srcFile),
);
+17 -54
View File
@@ -768,62 +768,25 @@ const WORKSPACE_PACKAGE_ALIAS_ENTRIES: WorkspacePackageAliasEntry[] = [
srcFile: "read-byte-stream-with-limit.ts",
distFile: "read-byte-stream-with-limit.mjs",
},
{
...(
[
["", "index"],
["boolean-coercion", "boolean-coercion"],
["error-coercion", "error-coercion"],
["number-coercion", "number-coercion"],
["record-coerce", "record-coerce"],
["result", "result"],
["string-coerce", "string-coerce"],
["string-normalization", "string-normalization"],
["utf16-slice", "utf16-slice"],
] as const
).map(([subpath, file]) => ({
packageName: "@openclaw/normalization-core",
packageDir: "normalization-core",
subpath: "",
srcFile: "index.ts",
distFile: "index.mjs",
},
{
packageName: "@openclaw/normalization-core",
packageDir: "normalization-core",
subpath: "boolean-coercion",
srcFile: "boolean-coercion.ts",
distFile: "boolean-coercion.mjs",
},
{
packageName: "@openclaw/normalization-core",
packageDir: "normalization-core",
subpath: "error-coercion",
srcFile: "error-coercion.ts",
distFile: "error-coercion.mjs",
},
{
packageName: "@openclaw/normalization-core",
packageDir: "normalization-core",
subpath: "number-coercion",
srcFile: "number-coercion.ts",
distFile: "number-coercion.mjs",
},
{
packageName: "@openclaw/normalization-core",
packageDir: "normalization-core",
subpath: "record-coerce",
srcFile: "record-coerce.ts",
distFile: "record-coerce.mjs",
},
{
packageName: "@openclaw/normalization-core",
packageDir: "normalization-core",
subpath: "string-coerce",
srcFile: "string-coerce.ts",
distFile: "string-coerce.mjs",
},
{
packageName: "@openclaw/normalization-core",
packageDir: "normalization-core",
subpath: "string-normalization",
srcFile: "string-normalization.ts",
distFile: "string-normalization.mjs",
},
{
packageName: "@openclaw/normalization-core",
packageDir: "normalization-core",
subpath: "utf16-slice",
srcFile: "utf16-slice.ts",
distFile: "utf16-slice.mjs",
},
subpath,
srcFile: `${file}.ts`,
distFile: `${file}.mjs`,
})),
{
packageName: "@openclaw/retry",
packageDir: "retry",
+4
View File
@@ -431,6 +431,10 @@ export const sharedVitestConfig = {
"record-coerce.ts",
),
},
{
find: "@openclaw/normalization-core/result",
replacement: path.join(repoRoot, "packages", "normalization-core", "src", "result.ts"),
},
{
find: "@openclaw/normalization-core/string-coerce",
replacement: path.join(
+3
View File
@@ -145,6 +145,9 @@
"@openclaw/normalization-core/record-coerce": [
"./packages/normalization-core/src/record-coerce.ts"
],
"@openclaw/normalization-core/result": [
"./packages/normalization-core/src/result.ts"
],
"@openclaw/normalization-core/string-coerce": [
"./packages/normalization-core/src/string-coerce.ts"
],
+3 -25
View File
@@ -441,37 +441,15 @@ function buildMarkdownCoreDistEntries(): Record<string, string> {
}
function buildNormalizationCoreDistEntries(): Record<string, string> {
return {
index: "packages/normalization-core/src/index.ts",
"boolean-coercion": "packages/normalization-core/src/boolean-coercion.ts",
"error-coercion": "packages/normalization-core/src/error-coercion.ts",
"number-coercion": "packages/normalization-core/src/number-coercion.ts",
"record-coerce": "packages/normalization-core/src/record-coerce.ts",
"string-coerce": "packages/normalization-core/src/string-coerce.ts",
"string-normalization": "packages/normalization-core/src/string-normalization.ts",
"utf16-slice": "packages/normalization-core/src/utf16-slice.ts",
};
return buildPackageDistEntriesFromExports("normalization-core");
}
function buildRetryDistEntries(): Record<string, string> {
return {
index: "packages/retry/src/index.ts",
};
return buildPackageDistEntriesFromExports("retry");
}
function buildMediaCoreDistEntries(): Record<string, string> {
return {
index: "packages/media-core/src/index.ts",
base64: "packages/media-core/src/base64.ts",
constants: "packages/media-core/src/constants.ts",
"content-length": "packages/media-core/src/content-length.ts",
"file-name": "packages/media-core/src/file-name.ts",
"inbound-path-policy": "packages/media-core/src/inbound-path-policy.ts",
"inline-image-data-url": "packages/media-core/src/inline-image-data-url.ts",
"media-source-url": "packages/media-core/src/media-source-url.ts",
mime: "packages/media-core/src/mime.ts",
"read-byte-stream-with-limit": "packages/media-core/src/read-byte-stream-with-limit.ts",
};
return buildPackageDistEntriesFromExports("media-core");
}
function buildPackageDistEntriesFromExports(packageDir: string): Record<string, string> {