Carry qualified card search repair through 18380

Normal parent merge; only the five qualified card/search paths change. Preserve every other owned and upstream blob.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-31 10:33:39 -05:00
commit cf87b23cbd
5 files changed
+141 -12

No files matched your search

@@ -11,6 +11,7 @@ import {
issueDocuments,
issueAccessGrants,
issueLabels,
issueWorkProducts,
issues,
labels,
projectAccessMembers,
@@ -174,6 +175,87 @@ describeEmbeddedPostgres("companySearchService", () => {
return id;
}
it("paginates issue-result-only searches before unrelated entity competition", async () => {
const companyId = await createCompany();
const now = Date.now();
const older = await createIssue(companyId, { title: "Launch older", updatedAt: new Date(now - 2_000) });
await createIssue(companyId, { title: "Launch newer", updatedAt: new Date(now - 1_000) });
await createAgent(companyId, { name: "Launch agent", updatedAt: new Date(now) });
await createProject(companyId, { name: "Launch project", updatedAt: new Date(now + 1_000) });
const holder = await createIssue(companyId, { title: "Artifact holder" });
await db.insert(issueWorkProducts).values({ companyId, issueId: holder, type: "artifact", provider: "paperclip",
title: "Launch artifact", status: "ready", updatedAt: new Date(now + 2_000) });
const query = companySearchQuerySchema.parse({ q: "launch", scope: "all", sort: "updated", limit: 1, offset: 1 });
const mixed = await svc.search(companyId, { ...query, limit: 10, offset: 0 });
expect(new Set(mixed.results.map(row => row.type))).toEqual(new Set(["issue", "agent", "project", "artifact"]));
const result = await svc.search(companyId, query, { issueResultsOnly: true });
expect(result.results.map(row => row.id)).toEqual([older]);
expect(result.countsByType).toMatchObject({ issue: 2, agent: 0, project: 0, artifact: 0 });
expect(result.filterOptionCounts.status.todo).toBe(2);
expect(result.hasMore).toBe(false);
for (const scope of ["agents", "projects", "artifacts"] as const) {
let membership: Date | null | undefined;
const excluded = await svc.search(companyId, { ...query, scope }, {
issueResultsOnly: true, onNextMembershipChange: at => { membership = at; },
});
expect(excluded.results).toEqual([]);
expect(Object.values(excluded.countsByType).every(count => count === 0)).toBe(true);
expect(excluded.filterOptionCounts.status).toEqual({});
expect(excluded.hasMore).toBe(false);
expect(membership).toBeNull();
}
let deadline: Date | null = null;
await svc.search(companyId, { ...query, updatedWithin: "1h" }, {
issueResultsOnly: true, onNextMembershipChange: at => { deadline = at; },
});
expect(deadline).toEqual(new Date(now - 2_000 + 3_600_001));
});
it.each(["all", "comments", "documents"] as const)("preserves issue-result-only %s text matching and read boundaries", async scope => {
const previous = process.env.PAPERCLIP_ISSUE_PRIVACY_MODE;
process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = "enforce";
try {
const companyId = await createCompany();
const comment = await createIssue(companyId, { title: "Comment context" });
const document = await createIssue(companyId, { title: "Document context" });
const privateId = randomUUID();
await createIssue(companyId, { id: privateId, title: "Private context", visibility: "private",
privacyRootIssueId: privateId, responsibleUserId: "private-owner" });
const foreign = await createIssue(await createCompany(), { title: "Foreign context" });
const hidden = await createIssue(companyId, { title: "Hidden context", hiddenAt: new Date() });
for (const issueId of [comment, privateId, hidden]) await db.insert(issueComments).values({ companyId, issueId, body: "Needle context" });
const foreignCompany = await db.select({ companyId: issues.companyId }).from(issues).where(sql`${issues.id} = ${foreign}`);
await db.insert(issueComments).values({ companyId: foreignCompany[0]!.companyId, issueId: foreign, body: "Needle context" });
for (const issueId of [document, privateId, hidden]) {
const id = randomUUID();
await db.insert(documents).values({ id, companyId, title: "Context", latestBody: "Needle context", format: "markdown" });
await db.insert(issueDocuments).values({ companyId, issueId, documentId: id, key: "brief" });
}
const condition = await issueReadSqlCondition(db, { type: "board", userId: "reader", source: "session" });
const result = await svc.search(companyId, companySearchQuerySchema.parse({ q: "needle", scope }), {
issueResultsOnly: true, issueReadCondition: condition,
});
expect(new Set(result.results.map(row => row.id))).toEqual(new Set(scope === "all" ? [comment, document] : [scope === "comments" ? comment : document]));
expect(result.results.every(row => row.type === "issue")).toBe(true);
expect(result.countsByType).toMatchObject({ agent: 0, project: 0, artifact: 0 });
} finally {
if (previous === undefined) delete process.env.PAPERCLIP_ISSUE_PRIVACY_MODE;
else process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = previous;
}
});
it("keeps issue-result-only loosen counts inside the selected result kinds", async () => {
const companyId = await createCompany();
await createIssue(companyId, { title: "Launch done", status: "done" });
await createAgent(companyId, { name: "Launch agent" });
await createProject(companyId, { name: "Launch project" });
const result = await svc.search(companyId, companySearchQuerySchema.parse({ q: "launch", status: "todo" }), { issueResultsOnly: true });
expect(result.results).toEqual([]);
expect(result.zeroResults).toMatchObject({ unfilteredTotal: 1,
loosenSuggestions: [{ filter: "status", resultCount: 1, additionalCount: 1 }] });
expect(result.filterOptionCounts.status.done).toBe(1);
});
it("keeps exact entity names ahead of speculative task typos and rejects empty quotes", async () => {
const companyId = await createCompany();
const agentId = await createAgent(companyId, { name: "Mibile" });
@@ -1,12 +1,13 @@
import { budgetService } from "../services/budgets.js";
import { documentService } from "../services/documents.js";
import { projectService } from "../services/projects.js";
import * as companySearch from "../services/company-search.js";
import { setImmediate as immediate } from "node:timers/promises";
import { randomUUID } from "node:crypto";
import { eq, sql } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { activityLog, agentWakeupRequests, agents, approvals, budgetIncidents, budgetPolicies, costEvents, companies, companyMemberships, createDb, documents, withDedicatedDbConnection,
heartbeatRuns, instanceSettings, issueComments, issues, statusCards, statusCardUpdates, documentRevisions } from "@paperclipai/db";
heartbeatRuns, instanceSettings, issueComments, issueWorkProducts, issues, projects, statusCards, statusCardUpdates, documentRevisions } from "@paperclipai/db";
import { companySearchQuerySchema, statusCardRefreshPolicySchema } from "@paperclipai/shared";
import { resolveExternalTestDatabaseUrl } from "./helpers/external-test-database.js";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
@@ -60,7 +61,7 @@ const support = external ? { supported: true } : await getEmbeddedPostgresTestSu
await db.delete(agentWakeupRequests); await db.delete(statusCardUpdates); await db.delete(statusCards);
await db.delete(documentRevisions); await db.delete(documents); await db.delete(activityLog);
await db.delete(issueComments); await db.delete(issues); await db.delete(heartbeatRuns);
await db.delete(instanceSettings); await db.delete(agents); await db.delete(companyMemberships); await db.delete(companies);
await db.delete(instanceSettings); await db.delete(projects); await db.delete(agents); await db.delete(companyMemberships); await db.delete(companies);
});
afterAll(async () => { await db?.$client.end({ timeout: 1 }); await temporary?.cleanup(); });
async function start(service = statusCardEvaluationService(db, { wakeup })) {
@@ -123,6 +124,38 @@ const support = external ? { supported: true } : await getEmbeddedPostgresTestSu
expect(errors).toEqual([]);
});
it("keeps mixed-search card preview, startup and ordinary issue delivery on issue-only pages", async () => {
const target = await issueService(db).create(companyId, { title: "Launch blocker", status: "blocked", priority: "medium" });
await vi.advanceTimersByTimeAsync(1_000);
const project = await projectService(db).create(companyId, { name: "Launch project" });
await db.insert(agents).values({ companyId, name: "Launch agent", role: "general", status: "idle", adapterType: "process" });
const holder = await issueService(db).create(companyId, { title: "Artifact holder", status: "todo", priority: "medium" });
await db.insert(issueWorkProducts).values({ companyId, issueId: holder.id, type: "artifact", provider: "paperclip",
title: "Launch artifact", status: "ready" });
compiledQuery = companySearchQuerySchema.parse({ q: "launch", scope: "all", sort: "updated", limit: 1 });
const created = await card();
expect(Object.keys(created.fingerprint!)).toEqual([target.id]);
const cards = statusCardService(db);
const [preview] = await cards.dryRun(created);
expect(preview!.result.results.map(row => row.id)).toEqual([target.id]);
await start(); await quiet();
await issueService(db).update(target.id, { status: "done" });
await settled();
const pending = (await cards.getById(created.id))!;
expect(pending.pendingChangeCount).toBe(1);
await vi.advanceTimersByTimeAsync(Math.max(0, pending.nextEvalAt!.getTime() - Date.now()));
await settled();
const generating = (await cards.getById(created.id))!;
expect(wakeup).toHaveBeenCalledOnce();
await writeback(created.id, generating.generatingIssueId!); await quiet();
await projectService(db).update(project.id, { name: "Launch project renamed" });
const updated = (await cards.getById(created.id))!;
expect((await cards.dryRun(updated))[0]!.result.results.map(row => row.id)).toEqual([target.id]);
expect(updated.fingerprint![target.id]!.status).toBe("done");
await quiet();
expect(errors).toEqual([]);
});
it("ordinary issue change schedules debounce, assigns one durable wake, writes a result and becomes quiet", async () => {
await start(); const created = await card(); await quiet();
const changed = await issueService(db).create(companyId, { title: "New blocker", status: "blocked", priority: "medium", createdByUserId: "board-user" });
+10 -8
View File
@@ -548,7 +548,9 @@ export function companySearchService(db: Db) {
search: async (
companyId: string,
query: CompanySearchQuery,
options?: { issueReadCondition?: SQL<boolean>; projectReadCondition?: SQL<boolean>; onNextMembershipChange?: (at: Date | null) => void },
options?: { issueReadCondition?: SQL<boolean>; projectReadCondition?: SQL<boolean>; onNextMembershipChange?: (at: Date | null) => void;
/** Restrict result kinds before pagination; retain the requested text-match scope. */
issueResultsOnly?: boolean },
): Promise<CompanySearchResponse> => {
const taskSearch = parseTaskSearch(query.q);
const normalizedQuery = taskSearch.normalizedQuery;
@@ -558,7 +560,7 @@ export function companySearchService(db: Db) {
const sort = query.sort;
const limit = query.limit;
const offset = query.offset;
if (!hasSearchText && !issueOnlyFiltersActive(query)) {
if ((!hasSearchText && !issueOnlyFiltersActive(query)) || (options?.issueResultsOnly && !scopeIncludesIssues(scope))) {
options?.onNextMembershipChange?.(null);
return {
query: query.q,
@@ -894,7 +896,7 @@ export function companySearchService(db: Db) {
const projectReadCondition = options?.projectReadCondition ?? sql<boolean>`true`;
async function fetchAgentRows() {
if (!hasSearchText || !scopeIncludesAgents(scope) || hasIssueOnlyFilters) return [];
if (options?.issueResultsOnly || !hasSearchText || !scopeIncludesAgents(scope) || hasIssueOnlyFilters) return [];
return db
.select({
id: agents.id,
@@ -911,7 +913,7 @@ export function companySearchService(db: Db) {
}
async function fetchProjectRows() {
if (!hasSearchText || !scopeIncludesProjects(scope) || hasIssueOnlyFilters) return [];
if (options?.issueResultsOnly || !hasSearchText || !scopeIncludesProjects(scope) || hasIssueOnlyFilters) return [];
return db
.select({
id: projects.id,
@@ -927,7 +929,7 @@ export function companySearchService(db: Db) {
}
async function countArtifacts(filters: CompanySearchQuery = query) {
if (!hasSearchText) return 0;
if (options?.issueResultsOnly || !hasSearchText) return 0;
const artifactIssueFilters = issueFilterConditions(companyId, filters);
const artifactIssueConditions = [
eq(issues.companyId, companyId),
@@ -995,7 +997,7 @@ export function companySearchService(db: Db) {
}
async function countAgents(filters: CompanySearchQuery = query) {
if (!hasSearchText || issueOnlyFiltersActive(filters)) return 0;
if (options?.issueResultsOnly || !hasSearchText || issueOnlyFiltersActive(filters)) return 0;
const rows = await db
.select({ count: sql<number>`count(*)::int` })
.from(agents)
@@ -1004,7 +1006,7 @@ export function companySearchService(db: Db) {
}
async function countProjects(filters: CompanySearchQuery = query) {
if (!hasSearchText || issueOnlyFiltersActive(filters)) return 0;
if (options?.issueResultsOnly || !hasSearchText || issueOnlyFiltersActive(filters)) return 0;
const rows = await db
.select({ count: sql<number>`count(*)::int` })
.from(projects)
@@ -1013,7 +1015,7 @@ export function companySearchService(db: Db) {
}
async function fetchArtifactRows() {
if (!hasSearchText || !scopeIncludesArtifacts(scope)) return [];
if (options?.issueResultsOnly || !hasSearchText || !scopeIncludesArtifacts(scope)) return [];
const result = await companyArtifactsService(db).list(companyId, {
q: normalizedQuery.slice(0, COMPANY_ARTIFACTS_MAX_QUERY_LENGTH),
limit: Math.min(fetchLimit, COMPANY_ARTIFACTS_MAX_LIMIT),
+2 -2
View File
@@ -601,7 +601,7 @@ export function statusCardService(
for (const storedQuery of card.queries) {
const query = companySearchQuerySchema.parse(storedQuery);
const response = await companySearchService(queryDb).search(card.companyId, query, {
issueReadCondition: options.issueReadCondition, onNextMembershipChange,
issueReadCondition: options.issueReadCondition, onNextMembershipChange, issueResultsOnly: true,
});
for (const result of response.results) {
if (result.type === "issue" && result.issue) issueMap.set(result.issue.id, result.issue);
@@ -1049,7 +1049,7 @@ export function statusCardService(
return {
query,
result: await searchSvc.search(card.companyId, query, {
issueReadCondition: options.issueReadCondition,
issueReadCondition: options.issueReadCondition, issueResultsOnly: true,
}),
};
}));