mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
chore(scripts): add debt ratchet to the session-accessor boundary guard (#101178)
* chore(scripts): add debt ratchet baseline to session-accessor boundary gate * fix(scripts): fail session-accessor debt gate until baseline ratchets down
This commit is contained in:
@@ -1684,6 +1684,7 @@
|
||||
"lint:tmp:no-raw-channel-fetch": "node scripts/check-no-raw-channel-fetch.mjs",
|
||||
"lint:tmp:no-raw-http2-imports": "node scripts/check-no-raw-http2-imports.mjs",
|
||||
"lint:tmp:session-accessor-boundary": "node scripts/check-session-accessor-boundary.mjs",
|
||||
"lint:tmp:session-accessor-boundary:gen": "node scripts/check-session-accessor-boundary.mjs --update-debt-baseline",
|
||||
"lint:tmp:session-transcript-reader-boundary": "node scripts/check-session-transcript-reader-boundary.mjs",
|
||||
"lint:tmp:tsgo-core-boundary": "node scripts/check-tsgo-core-boundary.mjs",
|
||||
"lint:ui:no-raw-window-open": "node scripts/check-no-raw-window-open.mjs",
|
||||
|
||||
@@ -537,6 +537,43 @@ export function findSessionLifecycleCleanupBoundaryViolations(content, fileName
|
||||
);
|
||||
}
|
||||
|
||||
// Source roots shared by the enforced boundary checks in main() and the debt
|
||||
// ratchet below; keeping one list prevents the two scans from drifting apart.
|
||||
const readSourceRootPaths = [
|
||||
"packages/memory-host-sdk/src/host",
|
||||
"extensions/discord/src/monitor",
|
||||
"extensions/memory-core/src",
|
||||
"extensions/telegram/src",
|
||||
"extensions/voice-call/src",
|
||||
"src/acp",
|
||||
"src/agents",
|
||||
"src/auto-reply",
|
||||
"src/commands",
|
||||
"src/config/sessions",
|
||||
"src/cron",
|
||||
"src/gateway",
|
||||
"src/infra",
|
||||
"src/plugins",
|
||||
"src/tui",
|
||||
];
|
||||
const writeSourceRootPaths = [
|
||||
"src/acp",
|
||||
"src/agents",
|
||||
"src/auto-reply",
|
||||
"src/commands",
|
||||
"src/config/sessions",
|
||||
"src/gateway",
|
||||
"src/plugins",
|
||||
"src/tui",
|
||||
];
|
||||
const transcriptWriterSourceRootPaths = [
|
||||
"src/agents/command",
|
||||
"src/agents/embedded-agent-runner",
|
||||
"src/config/sessions",
|
||||
"src/gateway/server-methods",
|
||||
"src/sessions",
|
||||
];
|
||||
|
||||
function declarationName(node) {
|
||||
if (ts.isFunctionDeclaration(node) && node.name) {
|
||||
return node.name.text;
|
||||
@@ -617,42 +654,166 @@ export function findMemoryHostSessionCorpusBoundaryViolations(content, fileName
|
||||
return violations;
|
||||
}
|
||||
|
||||
// Debt ratchet: the boundary checks above only scan files already on the
|
||||
// migrated lists, so unmigrated files could quietly gain new legacy call
|
||||
// sites. The checked-in baseline locks each unmigrated file's current legacy
|
||||
// call-site count per concern; any drift from the baseline fails the guard.
|
||||
export const sessionAccessorDebtBaselineRelativePath =
|
||||
"scripts/lib/session-accessor-debt-baseline.json";
|
||||
const debtBaselineRegenCommand = "pnpm lint:tmp:session-accessor-boundary:gen";
|
||||
|
||||
// Keys sorted alphabetically so the generated baseline JSON stays deterministic.
|
||||
const sessionAccessorDebtConcerns = [
|
||||
{
|
||||
key: "embeddedAgentSessionTarget",
|
||||
sourceRootPaths: ["extensions/voice-call/src"],
|
||||
migratedFiles: migratedEmbeddedAgentSessionTargetFiles,
|
||||
findViolations: findEmbeddedAgentSessionTargetViolations,
|
||||
},
|
||||
{
|
||||
key: "memoryHostSessionCorpus",
|
||||
sourceRootPaths: ["packages/memory-host-sdk/src/host"],
|
||||
migratedFiles: migratedMemoryHostSessionCorpusFiles,
|
||||
findViolations: findMemoryHostSessionCorpusBoundaryViolations,
|
||||
},
|
||||
{
|
||||
key: "sessionAccessorRead",
|
||||
sourceRootPaths: readSourceRootPaths,
|
||||
migratedFiles: new Set([
|
||||
...migratedSessionAccessorFiles,
|
||||
...migratedBundledPluginSessionAccessorFiles,
|
||||
]),
|
||||
findViolations: findSessionAccessorBoundaryViolations,
|
||||
},
|
||||
{
|
||||
key: "sessionAccessorWrite",
|
||||
sourceRootPaths: writeSourceRootPaths,
|
||||
migratedFiles: migratedSessionAccessorWriteFiles,
|
||||
findViolations: findSessionAccessorWriteBoundaryViolations,
|
||||
},
|
||||
{
|
||||
key: "sessionCompactManualTrim",
|
||||
sourceRootPaths: ["src/gateway/server-methods"],
|
||||
migratedFiles: migratedSessionCompactManualTrimFiles,
|
||||
findViolations: findSessionCompactManualTrimBoundaryViolations,
|
||||
},
|
||||
{
|
||||
key: "sessionLifecycleCleanup",
|
||||
sourceRootPaths: readSourceRootPaths,
|
||||
migratedFiles: migratedSessionLifecycleCleanupFiles,
|
||||
findViolations: findSessionLifecycleCleanupBoundaryViolations,
|
||||
},
|
||||
{
|
||||
key: "transcriptWriter",
|
||||
sourceRootPaths: transcriptWriterSourceRootPaths,
|
||||
migratedFiles: migratedTranscriptWriterFiles,
|
||||
findViolations: findTranscriptWriterBoundaryViolations,
|
||||
},
|
||||
];
|
||||
|
||||
function sortRecordByKey(record) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(record).toSorted(([left], [right]) =>
|
||||
left < right ? -1 : left > right ? 1 : 0,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** Counts legacy call sites per unmigrated file for every debt concern. */
|
||||
export async function collectSessionAccessorDebtCounts(repoRoot) {
|
||||
const counts = {};
|
||||
for (const concern of sessionAccessorDebtConcerns) {
|
||||
const violations = await collectFileViolations({
|
||||
repoRoot,
|
||||
sourceRoots: resolveSourceRoots(repoRoot, concern.sourceRootPaths),
|
||||
// Inverse of the enforcement skip: migrated files are held at zero by the
|
||||
// boundary checks, so the ratchet tracks only the unmigrated rest.
|
||||
skipFile: (filePath) =>
|
||||
concern.migratedFiles.has(normalizeRelativePath(path.relative(repoRoot, filePath))),
|
||||
findViolations: concern.findViolations,
|
||||
});
|
||||
const fileCounts = {};
|
||||
for (const violation of violations) {
|
||||
const relativePath = normalizeRelativePath(violation.path);
|
||||
fileCounts[relativePath] = (fileCounts[relativePath] ?? 0) + 1;
|
||||
}
|
||||
counts[concern.key] = sortRecordByKey(fileCounts);
|
||||
}
|
||||
return sortRecordByKey(counts);
|
||||
}
|
||||
|
||||
/** Ratchet compare: counts above baseline are regressions, below are improvements. */
|
||||
export function compareSessionAccessorDebt(currentCounts, baselineCounts) {
|
||||
const regressions = [];
|
||||
const improvements = [];
|
||||
const concerns = [
|
||||
...new Set([...Object.keys(baselineCounts), ...Object.keys(currentCounts)]),
|
||||
].toSorted();
|
||||
for (const concern of concerns) {
|
||||
const current = currentCounts[concern] ?? {};
|
||||
const baseline = baselineCounts[concern] ?? {};
|
||||
const filePaths = [...new Set([...Object.keys(baseline), ...Object.keys(current)])].toSorted();
|
||||
for (const filePath of filePaths) {
|
||||
const currentCount = current[filePath] ?? 0;
|
||||
const baselineCount = baseline[filePath] ?? 0;
|
||||
if (currentCount === baselineCount) {
|
||||
continue;
|
||||
}
|
||||
const entry = { concern, path: filePath, currentCount, baselineCount };
|
||||
if (currentCount > baselineCount) {
|
||||
regressions.push(entry);
|
||||
} else {
|
||||
improvements.push(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { regressions, improvements };
|
||||
}
|
||||
|
||||
// Improvements fail the guard too: passing silently would leave the baseline
|
||||
// stale, letting a later change reintroduce legacy call sites up to the old
|
||||
// count without tripping the ratchet.
|
||||
export function formatSessionAccessorDebtImprovements(improvements) {
|
||||
return [
|
||||
`Legacy session accessor debt dropped below ${sessionAccessorDebtBaselineRelativePath}:`,
|
||||
...improvements.map(
|
||||
(improvement) =>
|
||||
`- ${improvement.path} [${improvement.concern}]: ${improvement.currentCount} legacy call site(s), stale baseline allows ${improvement.baselineCount}`,
|
||||
),
|
||||
`Run \`${debtBaselineRegenCommand}\` to ratchet the baseline down and commit it.`,
|
||||
];
|
||||
}
|
||||
|
||||
function resolveDebtBaselinePath(repoRoot) {
|
||||
return path.join(repoRoot, ...sessionAccessorDebtBaselineRelativePath.split("/"));
|
||||
}
|
||||
|
||||
async function readSessionAccessorDebtBaseline(repoRoot) {
|
||||
try {
|
||||
return JSON.parse(await fs.readFile(resolveDebtBaselinePath(repoRoot), "utf8"));
|
||||
} catch (error) {
|
||||
if (error && typeof error === "object" && error.code === "ENOENT") {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeSessionAccessorDebtBaseline(repoRoot) {
|
||||
const counts = await collectSessionAccessorDebtCounts(repoRoot);
|
||||
await fs.writeFile(resolveDebtBaselinePath(repoRoot), `${JSON.stringify(counts, null, 2)}\n`);
|
||||
}
|
||||
|
||||
export async function main() {
|
||||
const repoRoot = resolveRepoRoot(import.meta.url);
|
||||
const readSourceRoots = resolveSourceRoots(repoRoot, [
|
||||
"packages/memory-host-sdk/src/host",
|
||||
"extensions/discord/src/monitor",
|
||||
"extensions/memory-core/src",
|
||||
"extensions/telegram/src",
|
||||
"extensions/voice-call/src",
|
||||
"src/acp",
|
||||
"src/agents",
|
||||
"src/auto-reply",
|
||||
"src/commands",
|
||||
"src/config/sessions",
|
||||
"src/cron",
|
||||
"src/gateway",
|
||||
"src/infra",
|
||||
"src/plugins",
|
||||
"src/tui",
|
||||
]);
|
||||
const writeSourceRoots = resolveSourceRoots(repoRoot, [
|
||||
"src/acp",
|
||||
"src/agents",
|
||||
"src/auto-reply",
|
||||
"src/commands",
|
||||
"src/config/sessions",
|
||||
"src/gateway",
|
||||
"src/plugins",
|
||||
"src/tui",
|
||||
]);
|
||||
const transcriptWriterSourceRoots = resolveSourceRoots(repoRoot, [
|
||||
"src/agents/command",
|
||||
"src/agents/embedded-agent-runner",
|
||||
"src/config/sessions",
|
||||
"src/gateway/server-methods",
|
||||
"src/sessions",
|
||||
]);
|
||||
if (process.argv.includes("--update-debt-baseline")) {
|
||||
await writeSessionAccessorDebtBaseline(repoRoot);
|
||||
console.log(`Wrote ${sessionAccessorDebtBaselineRelativePath}`);
|
||||
return;
|
||||
}
|
||||
const readSourceRoots = resolveSourceRoots(repoRoot, readSourceRootPaths);
|
||||
const writeSourceRoots = resolveSourceRoots(repoRoot, writeSourceRootPaths);
|
||||
const transcriptWriterSourceRoots = resolveSourceRoots(repoRoot, transcriptWriterSourceRootPaths);
|
||||
const readViolations = await collectFileViolations({
|
||||
repoRoot,
|
||||
sourceRoots: readSourceRoots,
|
||||
@@ -745,18 +906,50 @@ export async function main() {
|
||||
...sessionStoreRuntimeCompatViolations,
|
||||
];
|
||||
|
||||
if (violations.length === 0) {
|
||||
const baselineCounts = await readSessionAccessorDebtBaseline(repoRoot);
|
||||
if (!baselineCounts) {
|
||||
console.error(
|
||||
`Missing ${sessionAccessorDebtBaselineRelativePath}; run \`${debtBaselineRegenCommand}\` and commit it.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
const debt = compareSessionAccessorDebt(
|
||||
await collectSessionAccessorDebtCounts(repoRoot),
|
||||
baselineCounts,
|
||||
);
|
||||
|
||||
if (violations.length === 0 && debt.regressions.length === 0 && debt.improvements.length === 0) {
|
||||
console.log("session accessor boundary guard passed.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.error("Found legacy session store usage in session-accessor migrated files:");
|
||||
for (const violation of violations) {
|
||||
console.error(`- ${violation.path}:${violation.line}: ${violation.reason}`);
|
||||
if (violations.length > 0) {
|
||||
console.error("Found legacy session store usage in session-accessor migrated files:");
|
||||
for (const violation of violations) {
|
||||
console.error(`- ${violation.path}:${violation.line}: ${violation.reason}`);
|
||||
}
|
||||
console.error(
|
||||
"Use src/config/sessions/session-accessor.ts helpers for migrated read/write and transcript-writer paths. Expand file-backed SDK compatibility only as an explicit pre-SQLite migration decision.",
|
||||
);
|
||||
}
|
||||
if (debt.regressions.length > 0) {
|
||||
console.error(
|
||||
`Found new legacy session call sites in unmigrated files (counts exceed ${sessionAccessorDebtBaselineRelativePath}):`,
|
||||
);
|
||||
for (const regression of debt.regressions) {
|
||||
console.error(
|
||||
`- ${regression.path} [${regression.concern}]: ${regression.currentCount} legacy call site(s), baseline allows ${regression.baselineCount}`,
|
||||
);
|
||||
}
|
||||
console.error(
|
||||
`Use src/config/sessions/session-accessor.ts helpers instead of adding legacy call sites. If the increase is an intentional seam-owner change, run \`${debtBaselineRegenCommand}\` and commit the updated baseline.`,
|
||||
);
|
||||
}
|
||||
if (debt.improvements.length > 0) {
|
||||
for (const line of formatSessionAccessorDebtImprovements(debt.improvements)) {
|
||||
console.error(line);
|
||||
}
|
||||
}
|
||||
console.error(
|
||||
"Use src/config/sessions/session-accessor.ts helpers for migrated read/write and transcript-writer paths. Expand file-backed SDK compatibility only as an explicit pre-SQLite migration decision.",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"embeddedAgentSessionTarget": {},
|
||||
"memoryHostSessionCorpus": {},
|
||||
"sessionAccessorRead": {
|
||||
"extensions/telegram/src/bot-deps.ts": 1,
|
||||
"extensions/telegram/src/bot-message-context.session-recreate.test-support.ts": 2,
|
||||
"src/acp/control-plane/manager.background-task.ts": 2,
|
||||
"src/acp/control-plane/manager.core.ts": 1,
|
||||
"src/agents/agent-command.ts": 2,
|
||||
"src/agents/bash-tools.exec-approval-followup.ts": 2,
|
||||
"src/agents/command/session.ts": 5,
|
||||
"src/agents/main-session-restart-recovery.ts": 2,
|
||||
"src/agents/subagent-announce-delivery.ts": 3,
|
||||
"src/agents/subagent-announce-output.ts": 3,
|
||||
"src/agents/subagent-announce.test-support.ts": 1,
|
||||
"src/agents/subagent-capabilities.ts": 2,
|
||||
"src/agents/subagent-list.ts": 2,
|
||||
"src/agents/subagent-orphan-recovery.ts": 2,
|
||||
"src/agents/subagent-session-reconciliation.ts": 2,
|
||||
"src/agents/subagent-spawn.ts": 2,
|
||||
"src/agents/tools/transcripts-tool.ts": 2,
|
||||
"src/auto-reply/reply.triggers.trigger-handling.filters-usage-summary-current-model-provider.cases.ts": 2,
|
||||
"src/auto-reply/reply/commands-export-common.ts": 2,
|
||||
"src/auto-reply/reply/commands-name.ts": 2,
|
||||
"src/auto-reply/reply/commands-session-store.ts": 2,
|
||||
"src/auto-reply/reply/dispatch-acp-transcript.runtime.ts": 4,
|
||||
"src/auto-reply/reply/dispatch-from-config.ts": 6,
|
||||
"src/auto-reply/reply/get-reply-fast-path.ts": 4,
|
||||
"src/auto-reply/reply/get-reply-run.ts": 2,
|
||||
"src/commands/doctor-heartbeat-session-target.ts": 2,
|
||||
"src/commands/doctor-state-integrity.ts": 2,
|
||||
"src/commands/doctor/shared/codex-route-warnings.ts": 3,
|
||||
"src/config/sessions/cleanup-service.ts": 4,
|
||||
"src/config/sessions/session-accessor.ts": 25,
|
||||
"src/config/sessions/session-registry-maintenance.ts": 2,
|
||||
"src/config/sessions/store-load.ts": 4,
|
||||
"src/config/sessions/store.ts": 15,
|
||||
"src/config/sessions/test-helpers.ts": 2,
|
||||
"src/config/sessions/transcript.ts": 6,
|
||||
"src/cron/isolated-agent/session.ts": 3,
|
||||
"src/infra/approval-request-account-binding.ts": 2,
|
||||
"src/infra/heartbeat-runner.ts": 4,
|
||||
"src/plugins/runtime/runtime-agent.ts": 1
|
||||
},
|
||||
"sessionAccessorWrite": {
|
||||
"src/agents/embedded-agent-subscribe.handlers.compaction.runtime.ts": 2,
|
||||
"src/agents/session-suspension.ts": 2,
|
||||
"src/agents/subagent-orphan-recovery.ts": 3,
|
||||
"src/agents/subagent-spawn.ts": 2,
|
||||
"src/auto-reply/reply/commands-name.ts": 2,
|
||||
"src/auto-reply/reply/dispatch-from-config.ts": 2,
|
||||
"src/auto-reply/reply/session-fork.ts": 2,
|
||||
"src/commands/doctor-heartbeat-main-session-repair.ts": 2,
|
||||
"src/commands/doctor-session-state-providers.ts": 2,
|
||||
"src/commands/doctor-state-integrity.ts": 2,
|
||||
"src/commands/doctor/shared/codex-route-warnings.ts": 2,
|
||||
"src/config/sessions/plugin-host-cleanup.ts": 2,
|
||||
"src/config/sessions/session-accessor.ts": 11,
|
||||
"src/config/sessions/session-file.ts": 2,
|
||||
"src/config/sessions/session-registry-maintenance.ts": 2,
|
||||
"src/gateway/server-methods/agent.ts": 2,
|
||||
"src/gateway/server-methods/chat.ts": 2,
|
||||
"src/gateway/server-methods/sessions.ts": 3,
|
||||
"src/gateway/session-lifecycle-state.ts": 2,
|
||||
"src/gateway/test-helpers.mocks.ts": 1,
|
||||
"src/plugins/runtime/runtime-agent.ts": 2
|
||||
},
|
||||
"sessionCompactManualTrim": {},
|
||||
"sessionLifecycleCleanup": {
|
||||
"src/config/sessions/store-maintenance-operations.ts": 2,
|
||||
"src/config/sessions/store.ts": 5
|
||||
},
|
||||
"transcriptWriter": {
|
||||
"src/agents/embedded-agent-runner/compaction-hooks.ts": 2,
|
||||
"src/agents/embedded-agent-runner/thinking-replay-repair.ts": 2,
|
||||
"src/agents/embedded-agent-runner/tool-result-truncation.ts": 3,
|
||||
"src/agents/embedded-agent-runner/transcript-rewrite.ts": 2,
|
||||
"src/config/sessions/session-accessor.ts": 7,
|
||||
"src/config/sessions/store.ts": 2
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
allowedSessionStoreRuntimeFileBackedCompatExports,
|
||||
collectSessionStoreRuntimeFileBackedCompatExports,
|
||||
compareSessionAccessorDebt,
|
||||
findGatewaySessionCreateLifecycleViolations,
|
||||
findEmbeddedAgentSessionTargetViolations,
|
||||
findMemoryHostSessionCorpusBoundaryViolations,
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
findSessionLifecycleCleanupBoundaryViolations,
|
||||
findSessionStoreRuntimeFileBackedCompatExportViolations,
|
||||
findTranscriptWriterBoundaryViolations,
|
||||
formatSessionAccessorDebtImprovements,
|
||||
migratedBundledPluginSessionAccessorFiles,
|
||||
migratedEmbeddedAgentSessionTargetFiles,
|
||||
migratedMemoryHostSessionCorpusFiles,
|
||||
@@ -574,3 +576,55 @@ describe("session accessor boundary guard", () => {
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("session accessor debt ratchet", () => {
|
||||
it("flags unmigrated files whose legacy call-site count exceeds the baseline", () => {
|
||||
expect(
|
||||
compareSessionAccessorDebt(
|
||||
{
|
||||
sessionAccessorRead: { "src/a.ts": 3 },
|
||||
sessionAccessorWrite: { "src/new.ts": 1 },
|
||||
},
|
||||
{
|
||||
sessionAccessorRead: { "src/a.ts": 2 },
|
||||
sessionAccessorWrite: {},
|
||||
},
|
||||
),
|
||||
).toEqual({
|
||||
regressions: [
|
||||
{ concern: "sessionAccessorRead", path: "src/a.ts", currentCount: 3, baselineCount: 2 },
|
||||
{ concern: "sessionAccessorWrite", path: "src/new.ts", currentCount: 1, baselineCount: 0 },
|
||||
],
|
||||
improvements: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("passes when counts match the baseline", () => {
|
||||
expect(
|
||||
compareSessionAccessorDebt(
|
||||
{ sessionAccessorRead: { "src/a.ts": 2 } },
|
||||
{ sessionAccessorRead: { "src/a.ts": 2 } },
|
||||
),
|
||||
).toEqual({ regressions: [], improvements: [] });
|
||||
});
|
||||
|
||||
it("fails with a regen instruction when counts drop below the baseline", () => {
|
||||
const debt = compareSessionAccessorDebt(
|
||||
{ sessionAccessorRead: { "src/a.ts": 1 } },
|
||||
{ sessionAccessorRead: { "src/a.ts": 2, "src/gone.ts": 3 } },
|
||||
);
|
||||
expect(debt).toEqual({
|
||||
regressions: [],
|
||||
improvements: [
|
||||
{ concern: "sessionAccessorRead", path: "src/a.ts", currentCount: 1, baselineCount: 2 },
|
||||
{ concern: "sessionAccessorRead", path: "src/gone.ts", currentCount: 0, baselineCount: 3 },
|
||||
],
|
||||
});
|
||||
expect(formatSessionAccessorDebtImprovements(debt.improvements)).toEqual([
|
||||
"Legacy session accessor debt dropped below scripts/lib/session-accessor-debt-baseline.json:",
|
||||
"- src/a.ts [sessionAccessorRead]: 1 legacy call site(s), stale baseline allows 2",
|
||||
"- src/gone.ts [sessionAccessorRead]: 0 legacy call site(s), stale baseline allows 3",
|
||||
"Run `pnpm lint:tmp:session-accessor-boundary:gen` to ratchet the baseline down and commit it.",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user