mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
2fc91410e8
commit
6df498c3cd
@@ -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
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
@@ -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[] }
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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" };
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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
@@ -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"],
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user