mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-05 03:51:54 +01:00
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work.
> - Task indicators tell an operator whether work is active, waiting, or
complete.
> - An agent can yield until a saved monitor checks CI or another
external result.
> - The task currently keeps the In Progress icon between those runs.
> - Changing the stored status would affect monitor admission and
recovery.
> - This pull request adds a read-only Monitoring display state with a
blue clock.
> - Operators can see and filter scheduled waits while execution keeps
its existing rules.
## Linked Issues or Issue Description
**What existing behavior does this improve?**
Task status indicators, task collection filters, board columns, and
search facets.
**Current behavior**
A task that waits for a scheduled monitor shows the same In Progress
icon as active work.
**Proposed behavior**
Show Monitoring with a static blue clock when an agent-owned task has a
saved ordinary monitor and no active run, execution lock, review
participant, or active recovery action. Return to the normal task
indicator when the check starts. Preserve review, blocked, terminal, and
quota-recovery states.
**Reason and benefit**
Operators can distinguish scheduled waits from current agent work
without reading the task history.
**Breaking changes**
None to stored task statuses or execution rules. Task reads gain
optional displayStatus. Collections and search gain a separate read
filter. Existing status filters keep their lifecycle meaning.
Related historical work: #4396 covered a monitoring label exemption.
This change uses the saved monitor and a read projection.
## What Changed
- Add one SQL projection for task detail, compact lists, summaries,
relations, search results, and display-status counts.
- Show the blue clock in shared task icons and chips, task detail and
breadcrumbs, properties, references, list rows, recent navigation, and
agent task cards.
- Add Monitoring to task filtering, grouping, sorting, board columns,
and search facets. Prevent a drag from creating this derived state.
- Keep stored lifecycle statuses, monitor scheduling, checkout,
recovery, permissions, and agent status filters unchanged.
- Add database and UI regression coverage, full-page Storybook examples,
and product and access-map documentation.
## Verification
- Passed `pnpm -r typecheck`.
- Passed `pnpm build`.
- Passed `pnpm check:token-gates` and the map/catalog structural checks.
- Ran the new database suite on a disposable PostgreSQL 17 database. It
covers read surfaces, filter pagination/counts, queued/running checks,
rescheduling, completion, retained monitors, review, and recovery holds.
- Ran existing search, list, board, sidebar, task status, and filter
tests. Corrected board queries use displayStatus and eight columns.
- The full local `pnpm test:run` stopped in a chat integration shard: 12
failures (four timeouts and eight FIFO publication assertions). That run
spanned a rebase. A focused rerun on the current source passed all 21
selected cases without assertion or timeout changes. The original cause
is not established; this does not claim the full local suite passed.
Final-head required CI passed (ci / verify and ci / e2e, run
41488267209).
- Passed 82 tests for task API summaries, recovery precedence, search,
and optimistic updates; 54 tests for status cards, database reads, and
board moves; and 27 tests for blocker relations and recent-task cache
updates. One intermediate database setup hit its 20-second hook timeout;
the serial rerun passed.
- Greptile is 5/5 on d003ea672d. All
review threads are resolved. The separate optional Paperclip Review
ended early and returned neutral; GitHub rejected a direct rerun request
with HTTP 404. It is not counted as a completed review.
- Review fixes cover escalated recovery, is:open, optimistic status
changes, blocker-summary projection, and recent-task dedupe.
- Reviewed 15 screenshots from production pages and components with
isolated Storybook API fixtures: task detail/properties/breadcrumb,
list, groups, status filter, board, search/results/filter counts,
reference pills, sibling navigation, agent cards, picker, dashboard,
inbox, profile, watched status-card tasks, light theme, and mobile. This
does not claim deployed staging verification.
## Risks
- Read projections must stay consistent across full, compact, summary,
and search responses. Tests cover those paths.
- Monitoring is not a writable lifecycle status. The board does not
accept drops into its Monitoring lane or onto a monitored card. This
prevents a drag from implying a scheduled check. Move work into In
Progress through that lane instead.
- The read projection adds run/recovery existence checks. Existing
heartbeat_runs_company_issue_created_idx and
issue_recovery_actions_company_source_status_idx indexes cover the
company/task lookup. It adds no worker, timer, recurring query,
migration, or provider call. No production load benchmark is claimed.
- Older clients can ignore displayStatus. Existing lifecycle status
filters keep their behavior.
## Model Used
OpenAI Codex, GPT-6, with reasoning, repository tools, code execution,
and browser tools. The exact backend model identifier and context-window
size are not exposed in this session.
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
---------
Co-authored-by: Paperclip <noreply@paperclip.ing>
198 lines
11 KiB
TypeScript
198 lines
11 KiB
TypeScript
import { issueDisplayStatusSql } from "./issue-display-status.js";
|
|
import { sql, type SQL } from "drizzle-orm";
|
|
import { COMPANY_SEARCH_MAX_QUERY_LENGTH, COMPANY_SEARCH_MAX_TOKENS } from "@paperclipai/shared";
|
|
import { visibleIssueCondition } from "./issue-visibility.js";
|
|
|
|
// Only grammatical filler is ignored, only in multi-term queries, and never
|
|
// inside quotes. Keep negation and domain words (API, UI, PR, etc.) meaningful.
|
|
const FILLER = new Set(["a", "an", "the", "and", "of", "to", "for", "in", "on", "with"]);
|
|
export function escapeTaskSearchPattern(value: string) {
|
|
return value.replace(/[\\%_]/g, "\\$&");
|
|
}
|
|
|
|
export function parseTaskSearch(text: string) {
|
|
const normalizedQuery = text.slice(0, COMPANY_SEARCH_MAX_QUERY_LENGTH).trim().replace(/\s+/g, " ").toLowerCase();
|
|
const parsed = Array.from(normalizedQuery.matchAll(/"([^"]+)"|([^\s"]+)/g), (match) => ({
|
|
text: match[1] ?? match[2]!, quoted: match[1] !== undefined,
|
|
}));
|
|
const meaningful = parsed.filter((term) => term.quoted || !FILLER.has(term.text));
|
|
const uniqueTerms = new Map<string, { text: string; quoted: boolean }>();
|
|
for (const term of meaningful.length > 0 ? meaningful : parsed) {
|
|
uniqueTerms.set(term.text, { ...term, quoted: term.quoted || uniqueTerms.get(term.text)?.quoted === true });
|
|
}
|
|
const terms = [...uniqueTerms.values()].slice(0, COMPANY_SEARCH_MAX_TOKENS);
|
|
const tokens = terms.map((term) => term.text);
|
|
const phrase = tokens.join(" ");
|
|
// A copied/typed task identifier is navigation, never a fuzzy number match.
|
|
const identifier = /^([a-z][a-z0-9]*)[- ](\d+)$/i.exec(normalizedQuery) ?? /^([a-z]+)(\d+)$/i.exec(normalizedQuery);
|
|
const identifierQuery = identifier ? `${identifier[1]}-${identifier[2]}` : normalizedQuery;
|
|
const patterns = tokens.map((token) => `%${escapeTaskSearchPattern(token)}%`);
|
|
const containsPattern = `%${escapeTaskSearchPattern(phrase)}%`;
|
|
const startsWithPattern = `${escapeTaskSearchPattern(phrase)}%`;
|
|
return { normalizedQuery, terms, tokens, phrase, identifierQuery, patterns, containsPattern, startsWithPattern };
|
|
}
|
|
export type TaskSearch = ReturnType<typeof parseTaskSearch>;
|
|
|
|
function taskSearchAny(field: SQL, search: TaskSearch): SQL<boolean> {
|
|
return search.patterns.length === 0 ? sql`false`
|
|
: sql`(${sql.join(search.patterns.map((pattern) => sql`${field} ILIKE ${pattern}`), sql` OR `)})`;
|
|
}
|
|
// Short typeahead terms must start a word: UI must not match "build", and
|
|
// API must not match "Capistrano". Keep an indexable literal precondition.
|
|
export function taskSearchTermMatch(field: SQL, search: TaskSearch, index: number): SQL<boolean> {
|
|
const term = search.tokens[index]!;
|
|
const literal = sql<boolean>`${field} ILIKE ${search.patterns[index]!}`;
|
|
return /^[\p{L}]{1,3}$/u.test(term)
|
|
? sql`(${literal} AND ${field} ~* ${`(^|[^[:alnum:]])${term}`})`
|
|
: literal;
|
|
}
|
|
export function taskSearchFieldMatch(field: SQL, search: TaskSearch): SQL<boolean> {
|
|
return search.tokens.length === 0 ? sql`false`
|
|
: sql`(${sql.join(search.tokens.map((_, index) => taskSearchTermMatch(field, search, index)), sql` OR `)})`;
|
|
}
|
|
function coverage(matches: SQL[]): SQL<number> {
|
|
return matches.length === 0 ? sql`0`
|
|
: sql`(${sql.join(matches.map((match) => sql`CASE WHEN ${match} THEN 1 ELSE 0 END`), sql` + `)})`;
|
|
}
|
|
|
|
// Score bands are deliberately disjoint. Incidental comments, repeated terms,
|
|
// status and recency cannot outweigh a stronger kind of match.
|
|
export function taskSearchScore(search: TaskSearch): SQL<number> {
|
|
const n = search.tokens.length;
|
|
if (n === 0) return sql`0`;
|
|
return sql`(
|
|
CASE
|
|
WHEN m.ident_exact THEN 8000
|
|
WHEN m.ident_starts THEN 7000
|
|
WHEN m.title_exact THEN 6000
|
|
WHEN m.title_phrase AND m.title_coverage = ${n} THEN 5000
|
|
WHEN m.title_coverage = ${n} THEN 4000
|
|
WHEN m.issue_coverage = ${n} THEN 3000
|
|
WHEN m.token_coverage = ${n} THEN 2000
|
|
WHEN m.fuzzy_title THEN 1000
|
|
ELSE 0
|
|
END
|
|
+ m.title_word_coverage * 10
|
|
+ CASE WHEN m.title_starts THEN 30 ELSE 0 END
|
|
+ CASE m.status WHEN 'done' THEN 0 WHEN 'cancelled' THEN 0 ELSE 10 END
|
|
)::double precision`;
|
|
}
|
|
|
|
/** Shared task retrieval for company search and issue-list/command-palette search.
|
|
* Uses existing pg_trgm indexes and current rows: no derived corpus or worker.
|
|
* The tagged comment/document sets are evaluated once, not once per task.
|
|
*/
|
|
export function taskSearchCtes(companyId: string, search: TaskSearch, includeContext = true, fallbackFilters?: SQL, readCondition?: SQL<boolean>): SQL {
|
|
const n = search.tokens.length;
|
|
const comments = n === 0 || !includeContext ? sql`SELECT NULL::uuid AS issue_id, 0 AS ord WHERE false`
|
|
: sql.join(search.patterns.map((_, index) => sql`
|
|
SELECT c.issue_id, ${index}::int AS ord FROM issue_comments c
|
|
WHERE c.company_id = ${companyId} AND c.deleted_at IS NULL AND ${taskSearchTermMatch(sql`c.body`, search, index)}
|
|
GROUP BY c.issue_id
|
|
`), sql` UNION ALL `);
|
|
const documents = n === 0 || !includeContext ? sql`SELECT NULL::uuid AS issue_id, 0 AS ord WHERE false`
|
|
: sql.join(search.patterns.map((_, index) => sql`
|
|
SELECT d.issue_id, ${index}::int AS ord FROM issue_documents d
|
|
JOIN documents body ON body.id = d.document_id AND body.company_id = d.company_id
|
|
WHERE d.company_id = ${companyId} AND (${taskSearchTermMatch(sql`body.title`, search, index)} OR ${taskSearchTermMatch(sql`body.latest_body`, search, index)})
|
|
GROUP BY d.issue_id
|
|
`), sql` UNION ALL `);
|
|
const titleTerms = search.patterns.map((_, index) => taskSearchTermMatch(sql`issues.title`, search, index));
|
|
const issueTerms = search.patterns.map((_, index) => sql`(
|
|
${titleTerms[index]!} OR ${taskSearchTermMatch(sql`issues.identifier`, search, index)}
|
|
OR ${taskSearchTermMatch(sql`issues.description`, search, index)}
|
|
)`);
|
|
const commentTerms = search.patterns.map((_, index) => sql`issues.id IN (SELECT issue_id FROM comment_matches WHERE ord = ${index})`);
|
|
const documentTerms = search.patterns.map((_, index) => sql`issues.id IN (SELECT issue_id FROM document_matches WHERE ord = ${index})`);
|
|
const allTerms = issueTerms.map((term, index) => sql`(${term} OR ${commentTerms[index]!} OR ${documentTerms[index]!})`);
|
|
const phraseMatch = (field: SQL) => n > 0 ? sql`coalesce(${field} ILIKE ${search.containsPattern}, false)` : sql`false`;
|
|
const identExact = n > 0 ? sql`lower(issues.identifier) = ${search.identifierQuery}` : sql`false`;
|
|
const identStarts = n > 0 ? sql`issues.identifier ILIKE ${escapeTaskSearchPattern(search.identifierQuery) + "%"}` : sql`false`;
|
|
const fuzzyAllowed = !search.terms.some((term) => term.quoted)
|
|
&& search.terms.some((term) => /^[\p{L}]{4,255}$/u.test(term.text))
|
|
&& !/^[a-z][a-z0-9]*[- ]?\d+$/i.test(search.normalizedQuery);
|
|
const fuzzyTerms = search.terms.map((term, index) => {
|
|
if (!/^[\p{L}]{4,255}$/u.test(term.text)) return titleTerms[index]!;
|
|
// Bound both arguments before calling fuzzystrmatch (255-character limit).
|
|
// Cheap length checks prune word pairs before bounded edit-distance work.
|
|
const edits = sql`CASE WHEN least(char_length(word), ${Array.from(term.text).length}) >= 6 THEN 2
|
|
WHEN least(char_length(word), ${Array.from(term.text).length}) >= 5 THEN 1 ELSE 0 END`;
|
|
return sql`(${titleTerms[index]!} OR EXISTS (
|
|
SELECT 1 FROM regexp_split_to_table(lower(issues.title), '[^[:alnum:]]+') AS word
|
|
WHERE CASE WHEN char_length(word) BETWEEN 4 AND 255
|
|
AND abs(char_length(word) - ${Array.from(term.text).length}) <= ${edits}
|
|
THEN levenshtein_less_equal(${term.text}, word, ${edits}) <= ${edits}
|
|
ELSE false END
|
|
))`;
|
|
});
|
|
const fuzzy = fuzzyAllowed ? sql`CASE WHEN ${coverage(titleTerms)} = ${n} THEN false
|
|
ELSE (${sql.join(fuzzyTerms, sql` AND `)}) END` : sql`false`;
|
|
const wordTerms = search.tokens.map((token) => {
|
|
const escaped = token.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
return sql`issues.title ~* ${`(^|[^[:alnum:]_])${escaped}($|[^[:alnum:]_])`}`;
|
|
});
|
|
// Carry flags, not potentially large bodies, through materialized stages.
|
|
// The search page fetches descriptions only for its result window.
|
|
const flags = (fuzzyMatch: SQL) => sql`
|
|
SELECT issues.id, issues.identifier, issues.title,
|
|
issues.status, ${issueDisplayStatusSql()} AS display_status, issues.priority, issues.assignee_agent_id, issues.assignee_user_id,
|
|
issues.project_id, issues.created_at, issues.updated_at,
|
|
${identExact} AS ident_exact, ${identStarts} AS ident_starts,
|
|
${phraseMatch(sql`issues.identifier`)} AS ident_phrase,
|
|
${taskSearchFieldMatch(sql`issues.identifier`, search)} AS ident_token,
|
|
${n > 0 ? sql`lower(issues.title) = ${search.phrase}` : sql`false`} AS title_exact,
|
|
${n > 0 ? sql`issues.title ILIKE ${search.startsWithPattern}` : sql`false`} AS title_starts,
|
|
${phraseMatch(sql`issues.title`)} AS title_phrase,
|
|
${taskSearchFieldMatch(sql`issues.title`, search)} AS title_token,
|
|
${phraseMatch(sql`issues.description`)} AS desc_phrase,
|
|
${taskSearchFieldMatch(sql`issues.description`, search)} AS desc_token,
|
|
${coverage(titleTerms)} AS title_coverage,
|
|
${coverage(wordTerms)} AS title_word_coverage,
|
|
${coverage(issueTerms)} AS issue_coverage,
|
|
${coverage(commentTerms)} AS comment_coverage,
|
|
${coverage(documentTerms)} AS document_coverage,
|
|
${coverage(allTerms)} AS token_coverage,
|
|
${fuzzyMatch} AS fuzzy_title,
|
|
issues.id IN (SELECT issue_id FROM comment_matches) AS comment_match,
|
|
issues.id IN (SELECT issue_id FROM document_matches) AS document_match
|
|
FROM issues
|
|
`;
|
|
return sql`
|
|
WITH comment_matches AS MATERIALIZED (${comments}),
|
|
document_matches AS MATERIALIZED (${documents}),
|
|
literal_candidates AS MATERIALIZED (
|
|
SELECT issues.id FROM issues
|
|
WHERE issues.company_id = ${companyId} AND ${visibleIssueCondition()} AND ${readCondition ?? sql`true`}
|
|
AND ${n === 0 ? sql`${search.normalizedQuery.length === 0}` : sql`(
|
|
${taskSearchAny(sql`issues.title`, search)}
|
|
OR ${taskSearchAny(sql`issues.identifier`, search)}
|
|
OR ${taskSearchAny(sql`issues.description`, search)}
|
|
OR ${identStarts}
|
|
)`}
|
|
UNION SELECT issue_id FROM comment_matches
|
|
UNION SELECT issue_id FROM document_matches
|
|
), search_flags AS MATERIALIZED (
|
|
${flags(sql`false`)}
|
|
WHERE issues.company_id = ${companyId} AND ${visibleIssueCondition()} AND ${readCondition ?? sql`true`}
|
|
AND issues.id IN (SELECT id FROM literal_candidates)
|
|
), literal_matches AS MATERIALIZED (
|
|
SELECT * FROM search_flags
|
|
WHERE ${n === 0 ? sql`true` : sql`token_coverage = ${n} OR ident_exact OR ident_starts`}
|
|
), fuzzy_candidates AS MATERIALIZED (
|
|
SELECT issues.id FROM issues
|
|
WHERE NOT EXISTS (
|
|
SELECT 1 FROM literal_matches literal
|
|
JOIN issues ON issues.id = literal.id
|
|
${fallbackFilters ? sql`WHERE ${fallbackFilters}` : sql``}
|
|
)
|
|
AND issues.company_id = ${companyId} AND ${visibleIssueCondition()} AND ${readCondition ?? sql`true`}
|
|
AND ${fuzzy}
|
|
), matched AS MATERIALIZED (
|
|
SELECT * FROM literal_matches
|
|
UNION ALL
|
|
${flags(sql`true`)}
|
|
WHERE issues.id IN (SELECT id FROM fuzzy_candidates)
|
|
)
|
|
`;
|
|
}
|