diff --git a/AGENTS.md b/AGENTS.md index 9a54cafed02b..52df01101f6f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index f344e06dd687..d553db8097e8 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -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 diff --git a/packages/agent-core/src/harness/agent-harness.ts b/packages/agent-core/src/harness/agent-harness.ts index 1dfcb89fe2e1..1533af3feadf 100644 --- a/packages/agent-core/src/harness/agent-harness.ts +++ b/packages/agent-core/src/harness/agent-harness.ts @@ -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 = diff --git a/packages/agent-core/src/harness/env/nodejs.ts b/packages/agent-core/src/harness/env/nodejs.ts index ec48400a6171..03dc7cec58da 100644 --- a/packages/agent-core/src/harness/env/nodejs.ts +++ b/packages/agent-core/src/harness/env/nodejs.ts @@ -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(); } diff --git a/packages/agent-core/src/harness/session/jsonl-storage.ts b/packages/agent-core/src/harness/session/jsonl-storage.ts index 2c6b12fb5b08..eef111dd7e1f 100644 --- a/packages/agent-core/src/harness/session/jsonl-storage.ts +++ b/packages/agent-core/src/harness/session/jsonl-storage.ts @@ -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"); diff --git a/packages/agent-core/src/harness/types.test.ts b/packages/agent-core/src/harness/types.test.ts new file mode 100644 index 000000000000..13d22894cf6b --- /dev/null +++ b/packages/agent-core/src/harness/types.test.ts @@ -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"); + }); +}); diff --git a/packages/agent-core/src/harness/types.ts b/packages/agent-core/src/harness/types.ts index cba34e3a0220..8317ed09743f 100644 --- a/packages/agent-core/src/harness/types.ts +++ b/packages/agent-core/src/harness/types.ts @@ -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 = { 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(value: TValue): Result { - return { ok: true, value }; -} - -/** Create a failed {@link Result}. */ -export function err(error: TError): Result { - 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; diff --git a/packages/normalization-core/package.json b/packages/normalization-core/package.json index ce954d121bd5..eab642def161 100644 --- a/packages/normalization-core/package.json +++ b/packages/normalization-core/package.json @@ -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" } } diff --git a/packages/normalization-core/src/result.test.ts b/packages/normalization-core/src/result.test.ts new file mode 100644 index 000000000000..3ceb70fdd779 --- /dev/null +++ b/packages/normalization-core/src/result.test.ts @@ -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" }); + }); +}); diff --git a/packages/normalization-core/src/result.ts b/packages/normalization-core/src/result.ts new file mode 100644 index 000000000000..cde91554f29a --- /dev/null +++ b/packages/normalization-core/src/result.ts @@ -0,0 +1,12 @@ +/** Result of a fallible operation. Expected failures use the `ok: false` arm. */ +export type Result = { ok: true; value: TValue } | { ok: false; error: TError }; + +/** Create a successful {@link Result}. */ +export function ok(value: TValue): Result { + return { ok: true, value }; +} + +/** Create a failed {@link Result}. */ +export function err(error: TError): Result { + return { ok: false, error }; +} diff --git a/src/agents/code-mode.ts b/src/agents/code-mode.ts index caec2f7df3a7..f682df365fcb 100644 --- a/src/agents/code-mode.ts +++ b/src/agents/code-mode.ts @@ -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; type PendingBridgeState = PendingBridgeRequest & { promise: Promise; diff --git a/src/agents/code-mode.worker.ts b/src/agents/code-mode.worker.ts index 724323a5d5ce..78c50f5da175 100644 --- a/src/agents/code-mode.worker.ts +++ b/src/agents/code-mode.worker.ts @@ -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; type SerializedCodeModeNamespaceValue = | { kind: "array"; items: SerializedCodeModeNamespaceValue[] } diff --git a/src/agents/tool-search.ts b/src/agents/tool-search.ts index 06dd65db793d..2dbfd3098943 100644 --- a/src/agents/tool-search.ts +++ b/src/agents/tool-search.ts @@ -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; const TOOL_SEARCH_CODE_MODE_CHILD_SOURCE = String.raw` import vm from "node:vm"; diff --git a/src/auto-reply/reply/commands-config.ts b/src/auto-reply/reply/commands-config.ts index de59a38f3cdd..0d7535d972c7 100644 --- a/src/auto-reply/reply/commands-config.ts +++ b/src/auto-reply/reply/commands-config.ts @@ -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: { diff --git a/src/auto-reply/reply/commands-gating.test.ts b/src/auto-reply/reply/commands-gating.test.ts index 64c91a0e73dd..42495b64bb68 100644 --- a/src/auto-reply/reply/commands-gating.test.ts +++ b/src/auto-reply/reply/commands-gating.test.ts @@ -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 () => { diff --git a/src/cli/daemon-cli/status.gather.ts b/src/cli/daemon-cli/status.gather.ts index 671cfcaceab9..07553e95e1f9 100644 --- a/src/cli/daemon-cli/status.gather.ts +++ b/src/cli/daemon-cli/status.gather.ts @@ -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, diff --git a/src/config/config-misc.test.ts b/src/config/config-misc.test.ts index 1f894a04dca6..5d5c50fdd3db 100644 --- a/src/config/config-misc.test.ts +++ b/src/config/config-misc.test.ts @@ -1254,7 +1254,7 @@ describe("config paths", () => { it("sets, gets, and unsets nested values", () => { const root: Record = {}; const parsed = parseConfigPath("foo.bar"); - if (!parsed.ok || !parsed.path) { + if (!parsed.ok) { throw new Error("path parse failed"); } setConfigValueAtPath(root, parsed.path, 123); diff --git a/src/config/config-paths.ts b/src/config/config-paths.ts index 5fcd5a7bb0a9..292e00decd1f 100644 --- a/src/config/config-paths.ts +++ b/src/config/config-paths.ts @@ -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 { diff --git a/src/config/runtime-overrides.test.ts b/src/config/runtime-overrides.test.ts index 96aab1245707..5f81479fec72 100644 --- a/src/config/runtime-overrides.test.ts +++ b/src/config/runtime-overrides.test.ts @@ -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); }); diff --git a/src/config/runtime-overrides.ts b/src/config/runtime-overrides.ts index 2dbd1043c11d..d44bf4ea0cc1 100644 --- a/src/config/runtime-overrides.ts +++ b/src/config/runtime-overrides.ts @@ -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 { 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 { 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. */ diff --git a/src/daemon/launchd-restart-handoff.test.ts b/src/daemon/launchd-restart-handoff.test.ts index 947b0432a34e..70ef5c67b8c9 100644 --- a/src/daemon/launchd-restart-handoff.test.ts +++ b/src/daemon/launchd-restart-handoff.test.ts @@ -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"); diff --git a/src/daemon/launchd-restart-handoff.ts b/src/daemon/launchd-restart-handoff.ts index 9676974f77b0..2838f1968bf1 100644 --- a/src/daemon/launchd-restart-handoff.ts +++ b/src/daemon/launchd-restart-handoff.ts @@ -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; 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)); } } diff --git a/src/daemon/launchd.test.ts b/src/daemon/launchd.test.ts index 3e2e418a01bb..0c51efd40af9 100644 --- a/src/daemon/launchd.test.ts +++ b/src/daemon/launchd.test.ts @@ -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( diff --git a/src/daemon/launchd.ts b/src/daemon/launchd.ts index 167ee2ab8799..71ce5803ecc0 100644 --- a/src/daemon/launchd.ts +++ b/src/daemon/launchd.ts @@ -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" }; diff --git a/src/daemon/service-audit.ts b/src/daemon/service-audit.ts index b503fb913e9a..df198690da6f 100644 --- a/src/daemon/service-audit.ts +++ b/src/daemon/service-audit.ts @@ -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 }; } diff --git a/src/plugin-sdk/root-alias.cjs b/src/plugin-sdk/root-alias.cjs index bb10eb6339a5..775c7eb8a4e0 100644 --- a/src/plugin-sdk/root-alias.cjs +++ b/src/plugin-sdk/root-alias.cjs @@ -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", diff --git a/src/plugins/contracts/plugin-sdk-root-alias.test.ts b/src/plugins/contracts/plugin-sdk-root-alias.test.ts index 5067381d17a9..985acb9cf97a 100644 --- a/src/plugins/contracts/plugin-sdk-root-alias.test.ts +++ b/src/plugins/contracts/plugin-sdk-root-alias.test.ts @@ -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", diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index cee5dee7a983..243fb6bec648 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -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; cacheKey?: string; value?: unknown; -}): { ok: boolean; value?: Record; errors?: string[] } { - const value = params.value; - const schema = params.schema; +}): Result | undefined, string[]> { + const { schema, value } = params; if (!schema) { - return { ok: true, value: value as Record | undefined }; + return ok(value as Record | 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: [": must be object"] }; + return resultError([": must be object"]); } - return { ok: false, errors: [": config must be empty"] }; + return resultError([": 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 | undefined }; + return ok(result.value as Record | undefined); } - return { ok: false, errors: result.errors.map((error) => error.text) }; + return resultError(result.errors.map((error) => error.text)); } function isEmptyPluginConfigJsonSchema(schema: Record): 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; } diff --git a/src/plugins/plugin-sdk-native-resolver.test.ts b/src/plugins/plugin-sdk-native-resolver.test.ts index 3f0b682e8438..a6c0c62d0e55 100644 --- a/src/plugins/plugin-sdk-native-resolver.test.ts +++ b/src/plugins/plugin-sdk-native-resolver.test.ts @@ -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(); diff --git a/src/plugins/plugin-sdk-native-resolver.ts b/src/plugins/plugin-sdk-native-resolver.ts index 730fefcc33d4..0954d5886719 100644 --- a/src/plugins/plugin-sdk-native-resolver.ts +++ b/src/plugins/plugin-sdk-native-resolver.ts @@ -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"], diff --git a/src/plugins/sdk-alias.test.ts b/src/plugins/sdk-alias.test.ts index dc94d99a1988..8d5a5f30e37c 100644 --- a/src/plugins/sdk-alias.test.ts +++ b/src/plugins/sdk-alias.test.ts @@ -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), ); diff --git a/src/plugins/sdk-alias.ts b/src/plugins/sdk-alias.ts index eb9abd20dfc7..6503338e3dca 100644 --- a/src/plugins/sdk-alias.ts +++ b/src/plugins/sdk-alias.ts @@ -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", diff --git a/test/vitest/vitest.shared.config.ts b/test/vitest/vitest.shared.config.ts index ec6ffd673500..a816def64570 100644 --- a/test/vitest/vitest.shared.config.ts +++ b/test/vitest/vitest.shared.config.ts @@ -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( diff --git a/tsconfig.json b/tsconfig.json index 0359f0f44db0..8d7a4f83aee7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -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" ], diff --git a/tsdown.config.ts b/tsdown.config.ts index 8002185b91ca..6f31f93a9d4d 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -441,37 +441,15 @@ function buildMarkdownCoreDistEntries(): Record { } function buildNormalizationCoreDistEntries(): Record { - 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 { - return { - index: "packages/retry/src/index.ts", - }; + return buildPackageDistEntriesFromExports("retry"); } function buildMediaCoreDistEntries(): Record { - 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 {