Carry qualified card search repair through 18399

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:38 -05:00
commit bd78176b40
5 files changed
+141 -12

No files matched your search

@@ -163,6 +163,18 @@ Refresh decisions use the card's PostgreSQL row revision as a private compare-an
Company source hints do not preempt a future deadline owned by a failed evaluation (`retry`). Membership and policy deadlines remain eligible for source-driven rechecks, and actual due retries still run. An explicit policy edit can replace the retry through the existing card mutation path. Real PostgreSQL regressions hold the actual search while committing an ordinary policy edit, then verify the saved interval still fires. Persistent search failures stay quiet through issue hints and restart, retry at their saved deadline, and recover through an ordinary policy edit.
Card queries request the search owner's internal `issueResultsOnly` capability.
Nonissue result branches and their counts are excluded before pagination, so
project, agent or artifact changes cannot evict a watched issue from a bounded
page. This corrects the former accidental competition with results that cards
discarded after pagination. The saved query's text scope is unchanged: `all`
still matches issues through their comments and documents, and explicit comments
or documents scopes retain their task matches. Issue filters, privacy, tenant
boundaries, facets and membership-expiry deadlines remain search-owned. Card
preview, visibility counts, generation and writeback all use the same restriction.
Normal company search keeps its mixed result kinds by default. No extra
project/agent/artifact wake fanout or public query syntax is introduced.
A losing refresh cancels only an unclaimed task. It locks the exact company task
in the canonical issue-first order, checks its generation ledger, and preserves
terminal state and timestamps. Successful summary writeback records that ledger
@@ -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,
}),
};
}));