mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-01 15:35:12 +01:00
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:
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" });
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
};
|
||||
}));
|
||||
|
||||
Reference in new issue
Block a user