mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-30 21:49:18 +01:00
perf: omit unused descriptions from recovery candidates (#17695)
Keep unused task descriptions out of the recurring candidate read. Preserve the task content, state gates, and full current-task reload before repair. Verified 86 focused recovery tests, root typecheck, server build, a failing base counterfactual, all 55 CI checks, and fresh Apex 5/5 review with no unresolved comments. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
6bdcb70244
commit
1fbbef45a8
2 files changed
+75
-33
No files matched your search
@@ -1,5 +1,5 @@
|
|||||||
import { randomBytes, randomUUID } from "node:crypto";
|
import { randomBytes, randomUUID } from "node:crypto";
|
||||||
import { companies, createDb, issueRecoveryActions, issues } from "@paperclipai/db";
|
import { agents, companies, createDb, issueRecoveryActions, issues } from "@paperclipai/db";
|
||||||
import { createDatabaseResponseCapture, installDatabaseResponseCapture } from "@paperclipai/db/response-capture";
|
import { createDatabaseResponseCapture, installDatabaseResponseCapture } from "@paperclipai/db/response-capture";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
@@ -76,4 +76,42 @@ describe.skipIf(!support.supported)("recovery issue state reads", () => {
|
|||||||
}
|
}
|
||||||
expect((await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.id, action.id)))[0]).toEqual(action);
|
expect((await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.id, action.id)))[0]).toEqual(action);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps a candidate waiting for agent setup without reading its description", async () => {
|
||||||
|
const [company] = await db.insert(companies).values({
|
||||||
|
name: "Recovery candidate read",
|
||||||
|
issuePrefix: `R${randomUUID().slice(0, 7).toUpperCase()}`,
|
||||||
|
}).returning();
|
||||||
|
const [agent] = await db.insert(agents).values({
|
||||||
|
companyId: company.id,
|
||||||
|
name: "Preparing agent",
|
||||||
|
lifecycleState: "preparing",
|
||||||
|
}).returning();
|
||||||
|
const [issue] = await db.insert(issues).values({
|
||||||
|
companyId: company.id,
|
||||||
|
title: "Wait for agent setup",
|
||||||
|
description: randomBytes(128 * 1024).toString("base64"),
|
||||||
|
status: "todo",
|
||||||
|
assigneeAgentId: agent.id,
|
||||||
|
}).returning();
|
||||||
|
const enqueueWakeup = vi.fn(async () => {
|
||||||
|
throw new Error("Setup still owns this task");
|
||||||
|
});
|
||||||
|
const service = recoveryService(db, { enqueueWakeup });
|
||||||
|
const events: Array<Record<string, unknown>> = [];
|
||||||
|
const now = Date.now();
|
||||||
|
const capture = createDatabaseResponseCapture({ start: now - 100, end: now + 59_000 }, event => events.push(event));
|
||||||
|
installDatabaseResponseCapture(db, { agents, issues, issueRecoveryActions }, capture);
|
||||||
|
try {
|
||||||
|
expect(await service.reconcileStrandedAssignedIssues()).toMatchObject({ skipped: 1 });
|
||||||
|
} finally {
|
||||||
|
capture.flush();
|
||||||
|
}
|
||||||
|
expect(Number(events.at(-1)?.completedQueries)).toBeGreaterThan(5);
|
||||||
|
expect(Number(events.at(-1)?.responseValueBytes)).toBeLessThan(16_384);
|
||||||
|
expect(enqueueWakeup).not.toHaveBeenCalled();
|
||||||
|
expect((await db.select().from(issues).where(eq(issues.id, issue.id)))[0]).toEqual(issue);
|
||||||
|
expect((await db.select().from(agents).where(eq(agents.id, agent.id)))[0]).toEqual(agent);
|
||||||
|
expect(await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.companyId, company.id))).toEqual([]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -864,7 +864,7 @@ function formatIssueLinksForComment(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isStrandedIssueRecoveryIssue(
|
function isStrandedIssueRecoveryIssue(
|
||||||
issue: Pick<typeof issues.$inferSelect, "originKind">,
|
issue: Pick<RecoveryIssue, "originKind">,
|
||||||
) {
|
) {
|
||||||
return isStrandedIssueRecoveryOriginKind(issue.originKind);
|
return isStrandedIssueRecoveryOriginKind(issue.originKind);
|
||||||
}
|
}
|
||||||
@@ -936,6 +936,10 @@ function isRepeatedProductiveContinuationRecovery(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RecoveryIssue = Omit<typeof issues.$inferSelect, "description">;
|
||||||
|
|
||||||
|
const { description: _unusedIssueDescription, ...recoveryIssueFields } = getTableColumns(issues);
|
||||||
|
|
||||||
export function recoveryService(
|
export function recoveryService(
|
||||||
db: Db,
|
db: Db,
|
||||||
deps: {
|
deps: {
|
||||||
@@ -1251,7 +1255,7 @@ export function recoveryService(
|
|||||||
* Keep it separate from the broader monitor/delegated/legacy wait predicate.
|
* Keep it separate from the broader monitor/delegated/legacy wait predicate.
|
||||||
*/
|
*/
|
||||||
async function hasCurrentNativePassiveWait(
|
async function hasCurrentNativePassiveWait(
|
||||||
issue: typeof issues.$inferSelect,
|
issue: RecoveryIssue,
|
||||||
latestRun: LatestIssueRun,
|
latestRun: LatestIssueRun,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
if (
|
if (
|
||||||
@@ -1461,7 +1465,7 @@ export function recoveryService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function hasPersistedDurableWaitPath(
|
async function hasPersistedDurableWaitPath(
|
||||||
issue: typeof issues.$inferSelect,
|
issue: RecoveryIssue,
|
||||||
latestRun: LatestIssueRun,
|
latestRun: LatestIssueRun,
|
||||||
) {
|
) {
|
||||||
if (issue.monitorNextCheckAt) return true;
|
if (issue.monitorNextCheckAt) return true;
|
||||||
@@ -1526,7 +1530,7 @@ export function recoveryService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function nativeBlockedUnblockAction(
|
async function nativeBlockedUnblockAction(
|
||||||
issue: typeof issues.$inferSelect,
|
issue: RecoveryIssue,
|
||||||
latestRun: LatestIssueRun,
|
latestRun: LatestIssueRun,
|
||||||
database: Db = db,
|
database: Db = db,
|
||||||
) {
|
) {
|
||||||
@@ -1597,7 +1601,7 @@ export function recoveryService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function repairNativeBlockedWait(
|
async function repairNativeBlockedWait(
|
||||||
issue: typeof issues.$inferSelect,
|
issue: RecoveryIssue,
|
||||||
latestRun: NonNullable<LatestIssueRun>,
|
latestRun: NonNullable<LatestIssueRun>,
|
||||||
candidate: NonNullable<
|
candidate: NonNullable<
|
||||||
Awaited<ReturnType<typeof nativeBlockedUnblockAction>>
|
Awaited<ReturnType<typeof nativeBlockedUnblockAction>>
|
||||||
@@ -1801,7 +1805,7 @@ export function recoveryService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function wasTodoHandedBackDuringOrAfterLatestRun(
|
async function wasTodoHandedBackDuringOrAfterLatestRun(
|
||||||
issue: typeof issues.$inferSelect,
|
issue: RecoveryIssue,
|
||||||
latestRun: LatestIssueRun,
|
latestRun: LatestIssueRun,
|
||||||
) {
|
) {
|
||||||
if (issue.status !== "todo" || latestRun?.status !== "succeeded")
|
if (issue.status !== "todo" || latestRun?.status !== "succeeded")
|
||||||
@@ -2133,7 +2137,7 @@ export function recoveryService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function enqueueInitialAssignedTodoDispatch(
|
async function enqueueInitialAssignedTodoDispatch(
|
||||||
issue: typeof issues.$inferSelect,
|
issue: RecoveryIssue,
|
||||||
agentId: string,
|
agentId: string,
|
||||||
) {
|
) {
|
||||||
return deps.enqueueWakeup(agentId, {
|
return deps.enqueueWakeup(agentId, {
|
||||||
@@ -2166,7 +2170,7 @@ export function recoveryService(
|
|||||||
// contract is that nothing runs until the user types. Until a user-authored
|
// contract is that nothing runs until the user types. Until a user-authored
|
||||||
// comment exists on it, the issue is intentionally idle rather than stranded.
|
// comment exists on it, the issue is intentionally idle rather than stranded.
|
||||||
async function isOnboardingFirstTaskAwaitingUser(
|
async function isOnboardingFirstTaskAwaitingUser(
|
||||||
issue: typeof issues.$inferSelect,
|
issue: RecoveryIssue,
|
||||||
) {
|
) {
|
||||||
if (issue.originKind !== ONBOARDING_FIRST_TASK_ORIGIN_KIND) return false;
|
if (issue.originKind !== ONBOARDING_FIRST_TASK_ORIGIN_KIND) return false;
|
||||||
const [result] = await db.execute<{ awaiting: boolean }>(sql`SELECT
|
const [result] = await db.execute<{ awaiting: boolean }>(sql`SELECT
|
||||||
@@ -2175,7 +2179,7 @@ export function recoveryService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function isInvocationBudgetBlocked(
|
async function isInvocationBudgetBlocked(
|
||||||
issue: typeof issues.$inferSelect,
|
issue: RecoveryIssue,
|
||||||
agentId: string,
|
agentId: string,
|
||||||
) {
|
) {
|
||||||
const budgetBlock = await budgets.getInvocationBlock(
|
const budgetBlock = await budgets.getInvocationBlock(
|
||||||
@@ -2409,12 +2413,12 @@ export function recoveryService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isStrandedIssueRecoveryIssue(issue: typeof issues.$inferSelect) {
|
function isStrandedIssueRecoveryIssue(issue: RecoveryIssue) {
|
||||||
return issue.originKind === STRANDED_ISSUE_RECOVERY_ORIGIN_KIND;
|
return issue.originKind === STRANDED_ISSUE_RECOVERY_ORIGIN_KIND;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function buildNestedStrandedRecoveryLine(
|
async function buildNestedStrandedRecoveryLine(
|
||||||
issue: typeof issues.$inferSelect,
|
issue: RecoveryIssue,
|
||||||
prefix: string,
|
prefix: string,
|
||||||
) {
|
) {
|
||||||
const sourceIssueId = readNonEmptyString(issue.originId);
|
const sourceIssueId = readNonEmptyString(issue.originId);
|
||||||
@@ -2445,7 +2449,7 @@ export function recoveryService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolveStrandedRecoveryRouting(input: {
|
function resolveStrandedRecoveryRouting(input: {
|
||||||
issue: typeof issues.$inferSelect;
|
issue: RecoveryIssue;
|
||||||
latestRun: LatestIssueRun;
|
latestRun: LatestIssueRun;
|
||||||
}) {
|
}) {
|
||||||
const originalAgentId =
|
const originalAgentId =
|
||||||
@@ -2468,7 +2472,7 @@ export function recoveryService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function strandedRecoveryActionFingerprint(input: {
|
function strandedRecoveryActionFingerprint(input: {
|
||||||
issue: typeof issues.$inferSelect;
|
issue: RecoveryIssue;
|
||||||
recoveryCause: StrandedRecoveryCause;
|
recoveryCause: StrandedRecoveryCause;
|
||||||
latestRun: LatestIssueRun;
|
latestRun: LatestIssueRun;
|
||||||
}) {
|
}) {
|
||||||
@@ -2514,7 +2518,7 @@ export function recoveryService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildStrandedRecoveryActionEvidence(input: {
|
function buildStrandedRecoveryActionEvidence(input: {
|
||||||
issue: typeof issues.$inferSelect;
|
issue: RecoveryIssue;
|
||||||
latestRun: LatestIssueRun;
|
latestRun: LatestIssueRun;
|
||||||
previousStatus: StrandedPreviousStatus;
|
previousStatus: StrandedPreviousStatus;
|
||||||
recoveryCause: StrandedRecoveryCause;
|
recoveryCause: StrandedRecoveryCause;
|
||||||
@@ -2549,7 +2553,7 @@ export function recoveryService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function ensureSourceScopedStrandedRecoveryAction(input: {
|
async function ensureSourceScopedStrandedRecoveryAction(input: {
|
||||||
issue: typeof issues.$inferSelect;
|
issue: RecoveryIssue;
|
||||||
latestRun: LatestIssueRun;
|
latestRun: LatestIssueRun;
|
||||||
previousStatus: StrandedPreviousStatus;
|
previousStatus: StrandedPreviousStatus;
|
||||||
recoveryCause?: StrandedRecoveryCause;
|
recoveryCause?: StrandedRecoveryCause;
|
||||||
@@ -2680,7 +2684,7 @@ export function recoveryService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function ensureProviderQuotaWaitRecoveryMonitor(input: {
|
async function ensureProviderQuotaWaitRecoveryMonitor(input: {
|
||||||
issue: typeof issues.$inferSelect;
|
issue: RecoveryIssue;
|
||||||
latestRun: LatestIssueRun;
|
latestRun: LatestIssueRun;
|
||||||
actionId: string;
|
actionId: string;
|
||||||
agentId: string;
|
agentId: string;
|
||||||
@@ -2780,7 +2784,7 @@ export function recoveryService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildRecoveryIssueInPlaceEscalationComment(input: {
|
function buildRecoveryIssueInPlaceEscalationComment(input: {
|
||||||
issue: typeof issues.$inferSelect;
|
issue: RecoveryIssue;
|
||||||
previousStatus: StrandedPreviousStatus;
|
previousStatus: StrandedPreviousStatus;
|
||||||
latestRun: LatestIssueRun;
|
latestRun: LatestIssueRun;
|
||||||
prefix: string;
|
prefix: string;
|
||||||
@@ -2815,7 +2819,7 @@ export function recoveryService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function escalateStrandedRecoveryIssueInPlace(input: {
|
async function escalateStrandedRecoveryIssueInPlace(input: {
|
||||||
issue: typeof issues.$inferSelect;
|
issue: RecoveryIssue;
|
||||||
previousStatus: StrandedPreviousStatus;
|
previousStatus: StrandedPreviousStatus;
|
||||||
latestRun: LatestIssueRun;
|
latestRun: LatestIssueRun;
|
||||||
}, transaction?: {
|
}, transaction?: {
|
||||||
@@ -2954,7 +2958,7 @@ export function recoveryService(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openChildIssues(issue: typeof issues.$inferSelect) {
|
async function openChildIssues(issue: RecoveryIssue) {
|
||||||
return db
|
return db
|
||||||
.select({ id: issues.id, identifier: issues.identifier })
|
.select({ id: issues.id, identifier: issues.identifier })
|
||||||
.from(issues)
|
.from(issues)
|
||||||
@@ -2969,7 +2973,7 @@ export function recoveryService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function healthyOpenChildIssues(
|
async function healthyOpenChildIssues(
|
||||||
issue: Pick<typeof issues.$inferSelect, "id" | "companyId" | "projectWorkspaceId">,
|
issue: Pick<RecoveryIssue, "id" | "companyId" | "projectWorkspaceId">,
|
||||||
sameWorkspaceOnly = false,
|
sameWorkspaceOnly = false,
|
||||||
) {
|
) {
|
||||||
if (sameWorkspaceOnly && !issue.projectWorkspaceId) return [];
|
if (sameWorkspaceOnly && !issue.projectWorkspaceId) return [];
|
||||||
@@ -3001,7 +3005,7 @@ export function recoveryService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function resolveContinuationWaitingOnReview(
|
async function resolveContinuationWaitingOnReview(
|
||||||
issue: typeof issues.$inferSelect,
|
issue: RecoveryIssue,
|
||||||
) {
|
) {
|
||||||
const [existingBlockers, openChildren] = await Promise.all([
|
const [existingBlockers, openChildren] = await Promise.all([
|
||||||
existingUnresolvedBlockerIssues(issue.companyId, issue.id),
|
existingUnresolvedBlockerIssues(issue.companyId, issue.id),
|
||||||
@@ -3102,7 +3106,7 @@ export function recoveryService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function resolveDispositionRepairActionAsCovered(
|
async function resolveDispositionRepairActionAsCovered(
|
||||||
issue: typeof issues.$inferSelect,
|
issue: RecoveryIssue,
|
||||||
reason: string,
|
reason: string,
|
||||||
) {
|
) {
|
||||||
const active = await recoveryActionsSvc.getActiveForIssue(
|
const active = await recoveryActionsSvc.getActiveForIssue(
|
||||||
@@ -3136,7 +3140,7 @@ export function recoveryService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function ensureDispositionRepairAction(input: {
|
async function ensureDispositionRepairAction(input: {
|
||||||
issue: typeof issues.$inferSelect;
|
issue: RecoveryIssue;
|
||||||
latestRun: LatestIssueRun;
|
latestRun: LatestIssueRun;
|
||||||
fingerprint: string;
|
fingerprint: string;
|
||||||
attemptCount: number;
|
attemptCount: number;
|
||||||
@@ -3214,7 +3218,7 @@ export function recoveryService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function scheduleDispositionRepairAttempt(input: {
|
async function scheduleDispositionRepairAttempt(input: {
|
||||||
issue: typeof issues.$inferSelect;
|
issue: RecoveryIssue;
|
||||||
latestRun: LatestIssueRun;
|
latestRun: LatestIssueRun;
|
||||||
action: Awaited<ReturnType<typeof ensureDispositionRepairAction>>;
|
action: Awaited<ReturnType<typeof ensureDispositionRepairAction>>;
|
||||||
fingerprint: string;
|
fingerprint: string;
|
||||||
@@ -3651,7 +3655,7 @@ export function recoveryService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function escalateDispositionRepair(input: {
|
async function escalateDispositionRepair(input: {
|
||||||
issue: typeof issues.$inferSelect;
|
issue: RecoveryIssue;
|
||||||
latestRun: LatestIssueRun;
|
latestRun: LatestIssueRun;
|
||||||
fingerprint: string;
|
fingerprint: string;
|
||||||
attemptCount: number;
|
attemptCount: number;
|
||||||
@@ -3796,7 +3800,7 @@ export function recoveryService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function decidePersistedLegacyContinuation(
|
async function decidePersistedLegacyContinuation(
|
||||||
issue: typeof issues.$inferSelect,
|
issue: RecoveryIssue,
|
||||||
runId: string,
|
runId: string,
|
||||||
state: Awaited<ReturnType<typeof collectDispositionRepairSourceState>>,
|
state: Awaited<ReturnType<typeof collectDispositionRepairSourceState>>,
|
||||||
episode: LegacyDispositionEpisode,
|
episode: LegacyDispositionEpisode,
|
||||||
@@ -3876,7 +3880,7 @@ export function recoveryService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function reconcileDispositionRepair(
|
async function reconcileDispositionRepair(
|
||||||
issue: Pick<typeof issues.$inferSelect, "id" | "companyId">,
|
issue: Pick<RecoveryIssue, "id" | "companyId">,
|
||||||
latestRun: LatestIssueRun,
|
latestRun: LatestIssueRun,
|
||||||
options: { historicalAttemptCount?: number; legacyEpisode?: LegacyDispositionEpisode } = {},
|
options: { historicalAttemptCount?: number; legacyEpisode?: LegacyDispositionEpisode } = {},
|
||||||
): Promise<"queued" | "escalated" | "covered" | "skipped"> {
|
): Promise<"queued" | "escalated" | "covered" | "skipped"> {
|
||||||
@@ -4023,7 +4027,7 @@ export function recoveryService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function escalateStrandedAssignedIssue(input: {
|
async function escalateStrandedAssignedIssue(input: {
|
||||||
issue: typeof issues.$inferSelect;
|
issue: RecoveryIssue;
|
||||||
previousStatus: StrandedPreviousStatus;
|
previousStatus: StrandedPreviousStatus;
|
||||||
latestRun: LatestIssueRun;
|
latestRun: LatestIssueRun;
|
||||||
comment?: string;
|
comment?: string;
|
||||||
@@ -4390,7 +4394,7 @@ export function recoveryService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function scheduleProviderQuotaRecoveryMonitor(input: {
|
async function scheduleProviderQuotaRecoveryMonitor(input: {
|
||||||
issue: typeof issues.$inferSelect;
|
issue: RecoveryIssue;
|
||||||
latestRun: NonNullable<LatestIssueRun>;
|
latestRun: NonNullable<LatestIssueRun>;
|
||||||
classification: Extract<
|
classification: Extract<
|
||||||
NonNullable<AdapterFailureRecoveryClassification>,
|
NonNullable<AdapterFailureRecoveryClassification>,
|
||||||
@@ -4473,7 +4477,7 @@ export function recoveryService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getAdapterFailureRecoveryTargetAgentId(
|
function getAdapterFailureRecoveryTargetAgentId(
|
||||||
issue: typeof issues.$inferSelect,
|
issue: RecoveryIssue,
|
||||||
) {
|
) {
|
||||||
if (issue.status !== "in_review") return issue.assigneeAgentId;
|
if (issue.status !== "in_review") return issue.assigneeAgentId;
|
||||||
|
|
||||||
@@ -4488,7 +4492,7 @@ export function recoveryService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function hasPendingProviderQuotaRecoveryMonitor(
|
function hasPendingProviderQuotaRecoveryMonitor(
|
||||||
issue: typeof issues.$inferSelect,
|
issue: RecoveryIssue,
|
||||||
latestRun: LatestIssueRun,
|
latestRun: LatestIssueRun,
|
||||||
now: Date,
|
now: Date,
|
||||||
) {
|
) {
|
||||||
@@ -4510,7 +4514,7 @@ export function recoveryService(
|
|||||||
issueCreatedAtGte?: Date | null;
|
issueCreatedAtGte?: Date | null;
|
||||||
}) {
|
}) {
|
||||||
const candidates = await db
|
const candidates = await db
|
||||||
.select()
|
.select(recoveryIssueFields)
|
||||||
.from(issues)
|
.from(issues)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
|
|||||||
Reference in new issue
Block a user