mirror of
https://github.com/morluto/rea.git
synced 2026-11-15 19:23:24 +00:00
feat(errors): name a deadline's parameter or environment control in details.limit (#11172)
This commit is contained in:
1 parent
2d4a97b405
commit
863d82e370
16 files changed
+159
-50
No files matched your search
@@ -291,6 +291,17 @@ Error copying, attribution, redaction, and delivery must preserve these facts.
|
||||
Human-readable remediation explains the same failure; clients should not need
|
||||
to parse that sentence to identify an editable setting.
|
||||
|
||||
A deadline controlled by something other than a registered setting reports
|
||||
`details.limit` with `control_kind` instead of `setting`.
|
||||
`control_kind: "parameter"` names the per-call `parameter` and its `cli_flag`
|
||||
(`native_command_timeout_ms`, `native_ui_deadline_ms`,
|
||||
`pwntools_timeout_ms`) and applies on the next call.
|
||||
`control_kind: "environment"` names the `environment` variable REA reads when
|
||||
it starts (`REA_SWIFTC_COMPILE_TIMEOUT_MS`): `applies_at` is `next_startup`,
|
||||
or `next_invocation` for a one-shot invocation. Both carry `effective_value`,
|
||||
`unit`, `minimum`, `maximum` and `default_value`. `update_analysis_settings`
|
||||
changes neither. A limit without `control_kind` is a registered setting.
|
||||
|
||||
This output is breaking. Results and errors no longer carry the previous
|
||||
restart boolean or a single effect field, and there is no compatibility alias.
|
||||
Read `update_mode` to see whether the setter can change the active value, and
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { PlaywrightTrace } from "./playwrightTrace/playwrightTrace.js";
|
||||
import type { ApiContract } from "./apiContract/apiContract.js";
|
||||
import type { GraphqlContract } from "./apiContract/graphqlContract.js";
|
||||
import type { SavedAnalysisSnapshotPartialObservation } from "./savedAnalysisSnapshot.js";
|
||||
import type { AnalysisSettingLimit } from "./analysisSettings.js";
|
||||
import type { AnalysisLimit } from "./analysisSettings.js";
|
||||
import type { JsonValue } from "./jsonValue.js";
|
||||
import type { AnalysisCleanupRetry } from "./analysisErrorRecovery.js";
|
||||
import type { EnvironmentBundlePublication } from "./environmentBundleExport.js";
|
||||
@@ -100,7 +100,7 @@ export interface AnalysisErrorOptions extends ErrorOptions {
|
||||
readonly cleanupPrimary?: AnalysisError | undefined;
|
||||
/** Producer-owned, caller-safe facts retained through every typed projection. */
|
||||
readonly diagnostics?: Readonly<Record<string, JsonValue>> | undefined;
|
||||
readonly limit?: AnalysisSettingLimit | undefined;
|
||||
readonly limit?: AnalysisLimit | undefined;
|
||||
readonly continuingCallerGuidance?:
|
||||
| AnalysisContinuingCallerGuidance
|
||||
| undefined;
|
||||
@@ -165,9 +165,9 @@ export interface SourcePublicationRecovery {
|
||||
|
||||
/** Base class for expected analysis, provider, and session failures. */
|
||||
export abstract class AnalysisError extends Error {
|
||||
limit: Readonly<AnalysisSettingLimit> | undefined;
|
||||
limit: Readonly<AnalysisLimit> | undefined;
|
||||
/** Bind the accepted setting at its producing boundary. */
|
||||
bindLimit(limit: AnalysisSettingLimit): this {
|
||||
bindLimit(limit: AnalysisLimit): this {
|
||||
this.limit = Object.freeze({ ...limit });
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ import {
|
||||
type AnalysisErrorOptions,
|
||||
type AnalysisErrorTag,
|
||||
} from "./analysisErrorBase.js";
|
||||
import { invocationSettingLimit } from "./analysisSettingDefinitions.js";
|
||||
import { invocationLimit } from "./analysisSettingDefinitions.js";
|
||||
import { analysisErrorRefusal } from "./analysisErrorRefusal.js";
|
||||
import { analysisRefusalDetails } from "./analysisRefusal.js";
|
||||
|
||||
@@ -428,7 +428,7 @@ const projectError = (
|
||||
: {
|
||||
limit:
|
||||
callerLifetime === "invocation"
|
||||
? { ...invocationSettingLimit(primary.limit) }
|
||||
? { ...invocationLimit(primary.limit) }
|
||||
: { ...primary.limit },
|
||||
}),
|
||||
...(refusal === undefined
|
||||
|
||||
@@ -22,6 +22,7 @@ export const analysisErrorRefusal = (
|
||||
};
|
||||
if (
|
||||
error.limit !== undefined &&
|
||||
!("control_kind" in error.limit) &&
|
||||
!(
|
||||
"measuredExhaustion" in error.recovery &&
|
||||
error.recovery.measuredExhaustion === true
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { z } from "zod";
|
||||
import type {
|
||||
AnalysisControlLimit,
|
||||
AnalysisInvocationSettingLimit,
|
||||
AnalysisLimit,
|
||||
AnalysisSettingId,
|
||||
AnalysisSettingLimit,
|
||||
} from "./analysisSettings.js";
|
||||
@@ -650,7 +652,17 @@ export const analysisSettingLimit = (
|
||||
};
|
||||
|
||||
/** Re-scope an enforced limit for a caller whose runtime ended with this invocation. */
|
||||
export const invocationSettingLimit = ({
|
||||
export const invocationLimit = (
|
||||
limit: AnalysisLimit,
|
||||
): AnalysisInvocationSettingLimit | AnalysisControlLimit => {
|
||||
if (!("control_kind" in limit)) return invocationSettingLimit(limit);
|
||||
// A new invocation reads its environment without restarting a running REA.
|
||||
return limit.control_kind === "environment"
|
||||
? { ...limit, applies_at: "next_invocation" }
|
||||
: { ...limit };
|
||||
};
|
||||
|
||||
const invocationSettingLimit = ({
|
||||
applies_at: _appliesAt,
|
||||
scope: _scope,
|
||||
...facts
|
||||
|
||||
@@ -83,3 +83,35 @@ export interface AnalysisInvocationSettingLimit extends Omit<
|
||||
/** Launch-environment seed that a new invocation reads, when one exists. */
|
||||
readonly environment?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A limit whose control is not a registered analysis setting: a per-call
|
||||
* request parameter, or an environment variable REA reads when it starts. A
|
||||
* limit without `control_kind` is an `AnalysisSettingLimit` named by
|
||||
* `setting`. `update_analysis_settings` changes neither control.
|
||||
*/
|
||||
export type AnalysisControlLimit = {
|
||||
readonly effective_value: number;
|
||||
readonly unit: AnalysisSettingLimit["unit"];
|
||||
readonly minimum: number;
|
||||
readonly maximum: number;
|
||||
/** Value used when the control is omitted. */
|
||||
readonly default_value: number;
|
||||
} & (
|
||||
| {
|
||||
readonly control_kind: "parameter";
|
||||
readonly parameter: string;
|
||||
/** CLI spelling of the same per-call parameter. */
|
||||
readonly cli_flag: string;
|
||||
readonly applies_at: "next_call";
|
||||
}
|
||||
| {
|
||||
readonly control_kind: "environment";
|
||||
readonly environment: string;
|
||||
/** A one-invocation caller's next invocation reads it without a restart. */
|
||||
readonly applies_at: "next_startup" | "next_invocation";
|
||||
}
|
||||
);
|
||||
|
||||
/** The limit an error binds: a registered setting, or a parameter or environment control. */
|
||||
export type AnalysisLimit = AnalysisSettingLimit | AnalysisControlLimit;
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
timerDurationMsSchema,
|
||||
} from "../analysisResourceLimits.js";
|
||||
|
||||
import type { AnalysisControlLimit } from "../analysisSettings.js";
|
||||
import {
|
||||
fixedAllowanceRemediation,
|
||||
type FixedAllowanceKind,
|
||||
@@ -70,23 +71,38 @@ export const readNativeDeadline = (
|
||||
};
|
||||
};
|
||||
|
||||
/** Agent recovery for a caller-selected native deadline at today's default. */
|
||||
/** Agent recovery and limit facts for a caller-selected native deadline. */
|
||||
export const nativeDeadlineRecovery = (input: {
|
||||
readonly timeoutMs: number;
|
||||
readonly setting: string;
|
||||
readonly parameter: string;
|
||||
readonly cliFlag: string;
|
||||
readonly defaultMs: number;
|
||||
readonly phase: string;
|
||||
readonly unchanged: string;
|
||||
readonly detail?: string;
|
||||
}): { readonly userMessage: string; readonly remediationAction: string } => {
|
||||
}): {
|
||||
readonly userMessage: string;
|
||||
readonly remediationAction: string;
|
||||
readonly limit: AnalysisControlLimit;
|
||||
} => {
|
||||
const atMaximum = input.timeoutMs >= MAX_NODE_TIMER_DELAY_MS;
|
||||
const adjustment = atMaximum
|
||||
? `${input.setting} (${input.cliFlag} on the CLI) is already at the maximum ${String(MAX_NODE_TIMER_DELAY_MS)} ms; do not increase it.`
|
||||
: `Increase ${input.setting} above ${String(input.timeoutMs)} (integer milliseconds from 1 to ${String(MAX_NODE_TIMER_DELAY_MS)}; omitted means ${String(input.defaultMs)}), or pass ${input.cliFlag} on the CLI. A change applies on the next call.`;
|
||||
? `${input.parameter} (${input.cliFlag} on the CLI) is already at the maximum ${String(MAX_NODE_TIMER_DELAY_MS)} ms; do not increase it.`
|
||||
: `Increase ${input.parameter} above ${String(input.timeoutMs)} (integer milliseconds from 1 to ${String(MAX_NODE_TIMER_DELAY_MS)}; omitted means ${String(input.defaultMs)}), or pass ${input.cliFlag} on the CLI. A change applies on the next call.`;
|
||||
return {
|
||||
userMessage: `This request reached REA's ${input.phase} of ${String(input.timeoutMs)} ms (${input.setting}). This is a server/provider deadline, not the MCP client deadline.${input.detail ?? ""}`,
|
||||
userMessage: `This request reached REA's ${input.phase} of ${String(input.timeoutMs)} ms (${input.parameter}). This is a server/provider deadline, not the MCP client deadline.${input.detail ?? ""}`,
|
||||
remediationAction: `${adjustment} ${input.unchanged} Allow execution, cleanup, and delivery headroom in the outer MCP client deadline. Confirm outstanding work and cleanup before retrying.`,
|
||||
limit: {
|
||||
control_kind: "parameter",
|
||||
parameter: input.parameter,
|
||||
cli_flag: input.cliFlag,
|
||||
effective_value: input.timeoutMs,
|
||||
unit: "ms",
|
||||
minimum: 1,
|
||||
maximum: MAX_NODE_TIMER_DELAY_MS,
|
||||
default_value: input.defaultMs,
|
||||
applies_at: "next_call",
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
|
||||
import { AnalysisTimeoutError } from "../domain/analysisErrorCore.js";
|
||||
import { MAX_NODE_TIMER_DELAY_MS } from "../domain/analysisResourceLimits.js";
|
||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
|
||||
import {
|
||||
@@ -68,12 +69,22 @@ describe("translateNativeCommandFailure", () => {
|
||||
retryable: true,
|
||||
});
|
||||
expect(projected.message).toContain("1 ms");
|
||||
expect(projected.remediation.action).toContain(
|
||||
"REA_SWIFTC_COMPILE_TIMEOUT_MS",
|
||||
);
|
||||
expect(projected.remediation.action).not.toContain(
|
||||
"native_command_timeout_ms",
|
||||
);
|
||||
const limit = {
|
||||
control_kind: "environment",
|
||||
environment: "REA_SWIFTC_COMPILE_TIMEOUT_MS",
|
||||
effective_value: 1,
|
||||
unit: "ms",
|
||||
minimum: 1,
|
||||
maximum: MAX_NODE_TIMER_DELAY_MS,
|
||||
default_value: 120_000,
|
||||
applies_at: "next_startup",
|
||||
};
|
||||
expect(projected.details?.limit).toEqual(limit);
|
||||
// A one-invocation caller's next invocation reads it without a restart.
|
||||
expect(projectAnalysisError(error, "invocation").details?.limit).toEqual({
|
||||
...limit,
|
||||
applies_at: "next_invocation",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not infer a compiler timeout duration when it was not reported", () => {
|
||||
@@ -104,22 +115,22 @@ describe("translateNativeCommandFailure", () => {
|
||||
}),
|
||||
new ArtifactResourceScope(),
|
||||
60_000,
|
||||
{
|
||||
timeoutRecovery: {
|
||||
userMessage: "native command deadline",
|
||||
remediationAction: "native_command_timeout_ms",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(error).toBeInstanceOf(AnalysisTimeoutError);
|
||||
if (!(error instanceof AnalysisTimeoutError)) return;
|
||||
expect(error.timeoutMs).toBe(60_000);
|
||||
const projected = projectAnalysisError(error);
|
||||
expect(projected.remediation.action).toContain("native_command_timeout_ms");
|
||||
expect(projected.remediation.action).not.toContain(
|
||||
"REA_SWIFTC_COMPILE_TIMEOUT_MS",
|
||||
);
|
||||
expect(projectAnalysisError(error).details?.limit).toEqual({
|
||||
control_kind: "parameter",
|
||||
parameter: "native_command_timeout_ms",
|
||||
cli_flag: "--native-command-timeout-ms",
|
||||
effective_value: 60_000,
|
||||
unit: "ms",
|
||||
minimum: 1,
|
||||
maximum: MAX_NODE_TIMER_DELAY_MS,
|
||||
default_value: 60_000,
|
||||
applies_at: "next_call",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps compiler exit failures distinct from deadlines", () => {
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
AnalysisCapabilityUnavailableError,
|
||||
AnalysisResourceConstraintError,
|
||||
AnalysisTimeoutError,
|
||||
type AnalysisTimeoutRecovery,
|
||||
cancelledBy,
|
||||
carriedReason,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
@@ -32,10 +33,7 @@ import {
|
||||
/** Operation-owned text for a command deadline or unavailable requirement. */
|
||||
export interface NativeCommandFailurePresentation {
|
||||
readonly unavailableMessage?: string;
|
||||
readonly timeoutRecovery?: {
|
||||
readonly userMessage: string;
|
||||
readonly remediationAction: string;
|
||||
};
|
||||
readonly timeoutRecovery?: AnalysisTimeoutRecovery;
|
||||
}
|
||||
|
||||
/** Translate the command owner's facts once, preserving capture and cleanup authority. */
|
||||
@@ -116,7 +114,7 @@ export const translateNativeCommandFailure = (
|
||||
...(presentation.timeoutRecovery ??
|
||||
nativeDeadlineRecovery({
|
||||
timeoutMs,
|
||||
setting: "native_command_timeout_ms",
|
||||
parameter: "native_command_timeout_ms",
|
||||
cliFlag: "--native-command-timeout-ms",
|
||||
defaultMs: DEFAULT_NATIVE_COMMAND_TIMEOUT_MS,
|
||||
phase: "macOS native command deadline",
|
||||
@@ -135,6 +133,7 @@ export const translateNativeCommandFailure = (
|
||||
...options,
|
||||
userMessage: darwin.compileTimeout.userMessage,
|
||||
remediationAction: darwin.compileTimeout.remediationAction,
|
||||
limit: darwin.compileTimeout.limit,
|
||||
},
|
||||
)
|
||||
: new ProviderAdapterError(IDENTITY.id, operation, options);
|
||||
@@ -201,7 +200,7 @@ export const translateNativeCommandFailure = (
|
||||
...(presentation.timeoutRecovery ??
|
||||
nativeDeadlineRecovery({
|
||||
timeoutMs,
|
||||
setting: "native_command_timeout_ms",
|
||||
parameter: "native_command_timeout_ms",
|
||||
cliFlag: "--native-command-timeout-ms",
|
||||
defaultMs: DEFAULT_NATIVE_COMMAND_TIMEOUT_MS,
|
||||
phase: "macOS native command deadline",
|
||||
|
||||
@@ -91,7 +91,7 @@ export class NativeUiInterruptionControl {
|
||||
partialObservation,
|
||||
...nativeDeadlineRecovery({
|
||||
timeoutMs: this.#deadlineMs,
|
||||
setting: "native_ui_deadline_ms",
|
||||
parameter: "native_ui_deadline_ms",
|
||||
cliFlag: "--native-ui-deadline-ms",
|
||||
defaultMs: DEFAULT_NATIVE_UI_DEADLINE_MS,
|
||||
phase: "native UI observation deadline",
|
||||
|
||||
@@ -533,8 +533,12 @@ it("classifies a helper subprocess timeout as the selected UI deadline", async (
|
||||
const projected = projectAnalysisError(result.error);
|
||||
expect(projected.code).toBe("provider_timeout");
|
||||
expect(projected.message).toContain("of 2500 ms");
|
||||
expect(projected.message).toContain("native_ui_deadline_ms");
|
||||
expect(projected.remediation.action).toContain("above 2500");
|
||||
expect(projected.details?.limit).toMatchObject({
|
||||
control_kind: "parameter",
|
||||
parameter: "native_ui_deadline_ms",
|
||||
effective_value: 2_500,
|
||||
default_value: 180_000,
|
||||
});
|
||||
expect(projected.message).not.toContain("of 30000 ms");
|
||||
expect(projected.message).not.toContain("of 60000 ms");
|
||||
});
|
||||
|
||||
@@ -118,8 +118,13 @@ it("distinguishes the wall deadline from caller-selected producer resource budge
|
||||
);
|
||||
expect(projected.code).toBe("provider_timeout");
|
||||
expect(projected.message).toContain("4500 ms");
|
||||
expect(projected.message).toContain("pwntools_timeout_ms");
|
||||
expect(projected.remediation.action).toContain("--pwntools-timeout-ms");
|
||||
expect(projected.details?.limit).toMatchObject({
|
||||
control_kind: "parameter",
|
||||
parameter: "pwntools_timeout_ms",
|
||||
cli_flag: "--pwntools-timeout-ms",
|
||||
effective_value: 4_500,
|
||||
default_value: 30_000,
|
||||
});
|
||||
expect(projected.remediation.action).toContain("cpu_seconds=30");
|
||||
expect(projected.remediation.action).toContain("producer_limits");
|
||||
expect(projected.remediation.action).toContain("no REA restart");
|
||||
|
||||
@@ -225,7 +225,7 @@ const pwntoolsCommandContext = (
|
||||
const timeoutMs = context.timeoutMs ?? PWNTOOLS_LIMITS.command_timeout_ms;
|
||||
const deadline = nativeDeadlineRecovery({
|
||||
timeoutMs,
|
||||
setting: "pwntools_timeout_ms",
|
||||
parameter: "pwntools_timeout_ms",
|
||||
cliFlag: "--pwntools-timeout-ms",
|
||||
defaultMs: PWNTOOLS_LIMITS.command_timeout_ms,
|
||||
phase: "pwntools command deadline",
|
||||
@@ -254,6 +254,7 @@ const pwntoolsCommandContext = (
|
||||
ms: timeoutMs,
|
||||
userMessage: deadline.userMessage,
|
||||
remediationAction: deadline.remediationAction,
|
||||
limit: deadline.limit,
|
||||
},
|
||||
outputLimit: {
|
||||
limits: {
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
providerStartupTimeoutSchema,
|
||||
} from "../config/providerStartupTimeout.js";
|
||||
import { MAX_NODE_TIMER_DELAY_MS } from "../domain/analysisResourceLimits.js";
|
||||
import type { AnalysisControlLimit } from "../domain/analysisSettings.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
||||
import { AnalysisCleanupRelease } from "../domain/analysisCleanupRelease.js";
|
||||
@@ -197,6 +198,7 @@ export class DarwinProcessOwnershipInspectionError extends Error {
|
||||
readonly timeoutMs: number;
|
||||
readonly userMessage: string;
|
||||
readonly remediationAction: string;
|
||||
readonly limit: AnalysisControlLimit;
|
||||
}
|
||||
| undefined {
|
||||
const outcome = this.compilerOutcome;
|
||||
@@ -206,6 +208,16 @@ export class DarwinProcessOwnershipInspectionError extends Error {
|
||||
timeoutMs: outcome.timeoutMs,
|
||||
userMessage: `The macOS process ownership helper compiler reached its REA_SWIFTC_COMPILE_TIMEOUT_MS deadline of ${String(outcome.timeoutMs)} ms.`,
|
||||
remediationAction: swiftcCompileTimeoutRecovery(outcome.timeoutMs),
|
||||
limit: {
|
||||
control_kind: "environment",
|
||||
environment: "REA_SWIFTC_COMPILE_TIMEOUT_MS",
|
||||
effective_value: outcome.timeoutMs,
|
||||
unit: "ms",
|
||||
minimum: 1,
|
||||
maximum: MAX_NODE_TIMER_DELAY_MS,
|
||||
default_value: DEFAULT_SWIFTC_COMPILE_TIMEOUT_MS,
|
||||
applies_at: "next_startup",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
type ResourceConstraintRecovery,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import type { AnalysisSettingLimit } from "../domain/analysisSettings.js";
|
||||
import type { AnalysisLimit } from "../domain/analysisSettings.js";
|
||||
import type { ProviderOutcome } from "./ProviderOutcome.js";
|
||||
import type { ProviderProcessSnapshot } from "./ProviderProcess.js";
|
||||
import {
|
||||
@@ -55,8 +55,8 @@ export interface ProviderOutcomeContext {
|
||||
readonly userMessage?: string;
|
||||
/** The setting or action that changes that deadline. */
|
||||
readonly remediationAction?: string;
|
||||
/** The analysis setting that selected the deadline, when one did. */
|
||||
readonly limit?: AnalysisSettingLimit;
|
||||
/** The setting, parameter or environment control that selected the deadline. */
|
||||
readonly limit?: AnalysisLimit;
|
||||
};
|
||||
readonly outputLimit: {
|
||||
/** The budget that was exceeded, when the generic report is not enough. */
|
||||
|
||||
@@ -277,11 +277,16 @@ it.skipIf(process.platform === "win32")(
|
||||
expect(failure.message).toMatch(
|
||||
/ownership helper.*xcrun.*timed out after 3000 ms/iu,
|
||||
);
|
||||
expect(failure.message).toContain("REA_SWIFTC_COMPILE_TIMEOUT_MS");
|
||||
expect(failure.message).toContain("1..2147483647");
|
||||
expect(failure.message).toContain("120000 ms");
|
||||
expect(failure.message).toMatch(/restart REA/i);
|
||||
expect(failure.message).toMatch(/next ownership-helper compile/i);
|
||||
expect(failure.compileTimeout?.limit).toEqual({
|
||||
control_kind: "environment",
|
||||
environment: "REA_SWIFTC_COMPILE_TIMEOUT_MS",
|
||||
effective_value: 3_000,
|
||||
unit: "ms",
|
||||
minimum: 1,
|
||||
maximum: 2_147_483_647,
|
||||
default_value: 120_000,
|
||||
applies_at: "next_startup",
|
||||
});
|
||||
expect(failure.message).toContain(
|
||||
"error: PCH was compiled with module cache path that differs",
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user