Skip to content

Commit 88197ed

Browse files
mpstatonclaude
andcommitted
feat(search-results, prompt-runner): per-card × dismiss + crawls run 3-wide, not single-file
Two operator findings from live queue use, same evening it landed. Per-card dismiss (search-results): the header's "clear done" cleared the whole queue, but dismissing ONE search required expanding it to reach Mark complete. Every card now carries a tiny × above the expand caret — one tap dismisses just that search. A done card whose candidates were never reviewed asks once (× flips to ✓?, auto-resets after 4s) — the same conscience as the expanded confirm. The collapsed row splits into row-button + side-controls column, which also fixes the caret having been a span inside the row button. Bounded crawl parallelism (prompt-runner): the crawl consumer awaited each message inside its for-await loop, so concurrent submissions — exactly what the queue exists for — ran single-file. A batch of five put the last two past the caller's 600s ceiling (observed live: both NYT crawls timed out behind three others; elapsed 4:35-6:39 on the ones that survived). The loop now spawns per message under a 3-slot cap (MAX_CONCURRENT_CRAWLS to override); a released slot hands off directly to the next waiter. Files changed: - apps/search-results/src/SearchCard.svelte - apps/search-results/src/app.css - services/prompt-runner/src/crawl.ts Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013u3i9BoKeZRndqToQ3M5Q5
1 parent f61a5b3 commit 88197ed

3 files changed

Lines changed: 178 additions & 96 deletions

File tree

apps/search-results/src/SearchCard.svelte

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,25 @@
7979
ondismiss();
8080
}
8181
82+
// The collapsed-row × — dismiss THIS search without expanding (the header's
83+
// "clear done" clears the whole queue; this is its per-card twin). One tap
84+
// for failed/running/empty cards; a done card with candidates the operator
85+
// never reviewed asks once (the ✓? step, auto-reset) — same conscience as
86+
// the expanded Mark complete.
87+
let quickConfirm = $state(false);
88+
let quickConfirmTimer: ReturnType<typeof setTimeout> | undefined;
89+
function quickDismiss() {
90+
const unreviewed = results ? remaining : (card.result_summary?.count ?? 0);
91+
if (card.status === 'done' && unreviewed > 0 && !quickConfirm) {
92+
quickConfirm = true;
93+
clearTimeout(quickConfirmTimer);
94+
quickConfirmTimer = setTimeout(() => (quickConfirm = false), 4_000);
95+
return;
96+
}
97+
clearTimeout(quickConfirmTimer);
98+
ondismiss();
99+
}
100+
82101
// Retry (failed cards): resubmit the same entity + target, drop this card.
83102
async function retry() {
84103
retrying = true;
@@ -95,6 +114,7 @@
95114
</script>
96115

97116
<li class="srq-card status-{card.status}">
117+
<div class="srq-card-top">
98118
<button type="button" class="srq-card-row" onclick={toggle} aria-expanded={expanded}>
99119
<span class="srq-chip srq-chip-{card.target}">{TARGET_LABEL[card.target]}</span>
100120
<span class="srq-org" title={card.entity.org_slug}>{orgLabel}</span>
@@ -113,8 +133,24 @@
113133
{:else}
114134
<span class="srq-status srq-status-failed">failed</span>
115135
{/if}
116-
<span class="srq-caret">{expanded ? '' : ''}</span>
117136
</button>
137+
<span class="srq-card-side">
138+
<button
139+
type="button"
140+
class="srq-quick-x"
141+
class:confirming={quickConfirm}
142+
title={quickConfirm
143+
? `${results ? remaining : (card.result_summary?.count ?? 0)} candidate${(results ? remaining : (card.result_summary?.count ?? 0)) === 1 ? '' : 's'} not reviewed — click again to dismiss`
144+
: 'Dismiss this search'}
145+
onclick={quickDismiss}
146+
>
147+
{quickConfirm ? '✓?' : '×'}
148+
</button>
149+
<button type="button" class="srq-caret-btn" onclick={toggle} aria-expanded={expanded} aria-label={expanded ? 'Collapse' : 'Expand'}>
150+
{expanded ? '' : ''}
151+
</button>
152+
</span>
153+
</div>
118154

119155
{#if expanded}
120156
<div class="srq-card-body">

apps/search-results/src/app.css

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,17 @@
3030
.srq-card.status-done { border-color: color-mix(in srgb, var(--color-ok-text, #6ee7a8) 45%, var(--color-border, #2a2c33)); }
3131
.srq-card.status-failed { border-color: color-mix(in srgb, var(--color-error-text, #f3a3a3) 45%, var(--color-border, #2a2c33)); }
3232

33-
.srq-card-row { display: flex; align-items: center; gap: 0.45rem; width: 100%; background: transparent; border: 0; color: inherit; font: inherit; padding: 0.5rem 0.6rem; cursor: pointer; text-align: left; flex-wrap: wrap; }
33+
.srq-card-top { display: flex; align-items: stretch; }
34+
.srq-card-row { display: flex; align-items: center; gap: 0.45rem; flex: 1; min-width: 0; background: transparent; border: 0; color: inherit; font: inherit; padding: 0.5rem 0.6rem; cursor: pointer; text-align: left; flex-wrap: wrap; }
3435
.srq-card-row:hover { background: color-mix(in srgb, var(--color-border, #2a2c33) 40%, transparent); }
36+
/* the per-card controls column: tiny × (dismiss just this search) stacked
37+
above the expand caret */
38+
.srq-card-side { display: flex; flex-direction: column; align-items: center; justify-content: space-between; padding: 0.3rem 0.45rem 0.3rem 0; gap: 0.15rem; flex-shrink: 0; }
39+
.srq-quick-x { background: transparent; border: 0; color: var(--color-text-muted, #9aa0aa); font: inherit; font-size: 0.8rem; line-height: 1; padding: 1px 4px; border-radius: 3px; cursor: pointer; }
40+
.srq-quick-x:hover { color: var(--color-error-text, #f3a3a3); background: color-mix(in srgb, var(--color-border, #2a2c33) 50%, transparent); }
41+
.srq-quick-x.confirming { color: var(--color-error-text, #f3a3a3); border: 1px solid var(--color-error-text, #f3a3a3); font-size: 0.62rem; }
42+
.srq-caret-btn { background: transparent; border: 0; color: var(--color-text-muted, #9aa0aa); font: inherit; font-size: 0.8rem; line-height: 1; padding: 1px 4px; cursor: pointer; }
43+
.srq-caret-btn:hover { color: var(--color-text, #e7e7e7); }
3544
.srq-chip { font-size: 0.66rem; text-transform: uppercase; letter-spacing: 0.05em; padding: 1px 6px; border-radius: 3px; background: var(--color-border, #2a2c33); color: var(--color-text-muted, #cfd3da); flex-shrink: 0; }
3645
.srq-chip-team { background: color-mix(in srgb, var(--color-accent, #8ab4f8) 25%, var(--color-border, #2a2c33)); }
3746
.srq-org { font-size: 0.84rem; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%; }
@@ -40,7 +49,6 @@
4049
.srq-status-done { color: var(--color-ok-text, #6ee7a8); }
4150
.srq-status-failed { color: var(--color-error-text, #f3a3a3); }
4251
.srq-overdue { font-size: 0.62rem; padding: 0 5px; border-radius: 3px; background: var(--color-error-bg, #3a1717); color: var(--color-error-text, #f3a3a3); }
43-
.srq-caret { margin-left: auto; color: var(--color-text-muted, #9aa0aa); flex-shrink: 0; }
4452

4553
/* the arrival signal — a soft pulse on done cards */
4654
.srq-dot { width: 0.5rem; height: 0.5rem; border-radius: 50%; background: var(--color-ok-text, #6ee7a8); animation: srq-pulse 2s ease-in-out infinite; }

services/prompt-runner/src/crawl.ts

Lines changed: 131 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -132,105 +132,143 @@ OUTPUT: respond with ONLY a JSON object (no prose, no markdown fence):
132132
At most ${max} people.`;
133133
}
134134

135+
// Bounded parallelism. The search-results queue made concurrent crawls the
136+
// norm (N searches in flight across N orgs), and the old awaited-in-loop
137+
// consumer serialized them — a batch of five put the tail past the caller's
138+
// 600s ceiling (observed live 2026-07-24: both NYT crawls timed out behind
139+
// three others). Cap of 3 keeps parallel model turns modest for rate limits;
140+
// a released slot hands off directly to the next waiter.
141+
const MAX_CONCURRENT_CRAWLS = Number(process.env.MAX_CONCURRENT_CRAWLS ?? 3);
142+
let activeCrawls = 0;
143+
const crawlWaiters: (() => void)[] = [];
144+
145+
function acquireCrawlSlot(): Promise<void> {
146+
if (activeCrawls < MAX_CONCURRENT_CRAWLS) {
147+
activeCrawls += 1;
148+
return Promise.resolve();
149+
}
150+
return new Promise((resolve) => crawlWaiters.push(resolve));
151+
}
152+
153+
function releaseCrawlSlot(): void {
154+
const next = crawlWaiters.shift();
155+
if (next) next(); // the slot passes directly; activeCrawls stays counted
156+
else activeCrawls -= 1;
157+
}
158+
159+
type CrawlMsg = { json<T>(): T; reply?: string; respond(data: string): void };
160+
135161
export function registerCrawlHandler(nc: NatsConnection): void {
136162
(async () => {
137163
const sub = nc.subscribe('organization.crawl.requested');
138164
for await (const msg of sub) {
139-
const args = msg.json() as CrawlInput;
140-
const started = Date.now();
141-
console.log(JSON.stringify({ level: 'info', msg: 'crawl started', ...args }));
142-
try {
143-
if (!args.org_slug?.trim()) throw new Error('organization.crawl: org_slug is required');
144-
if (!['links', 'streams', 'team'].includes(args.target)) {
145-
throw new Error(`organization.crawl: unknown target ${String(args.target)}`);
146-
}
147-
const max = Math.min(Math.max(args.max_results ?? 12, 1), 25);
148-
149-
const detail = await natsJson<{ ok: boolean; org?: OrgDetail; error?: string }>(
150-
nc,
151-
'organization.detail.requested',
152-
{ org_slug: args.org_slug, client: args.client },
153-
);
154-
if (!detail.ok || !detail.org) throw new Error(detail.error || 'organization.detail failed');
155-
const org = detail.org;
156-
157-
const briefReply = await natsJson<{ ok: boolean; brief?: string | null }>(
158-
nc,
159-
'client.brief.get.requested',
160-
{ client: args.client },
161-
);
162-
const brief = briefReply.ok ? (briefReply.brief ?? null) : null;
163-
164-
const existing =
165-
args.target === 'streams'
166-
? (org.media_streams ?? []).map((e) => e?.url ?? '').filter(Boolean)
167-
: (org.org_links ?? []).map((e) => e?.url ?? '').filter(Boolean);
168-
169-
const request = buildRequest(promptFor(args.target, org, existing, brief, max), {
170-
model: CRAWL_MODEL,
171-
maxTokens: CRAWL_MAX_TOKENS,
172-
tools: ['web_search'],
173-
});
174-
const text = await runPrompt(request);
175-
const parsed = extractJson(text);
176-
177-
if (args.target === 'team') {
178-
const obj = (parsed ?? {}) as {
179-
source_urls?: unknown[];
180-
people?: Partial<CrawlPerson>[];
181-
filtered_note?: string;
182-
};
183-
const people: CrawlPerson[] = (obj.people ?? [])
184-
.filter((p) => typeof p?.name === 'string' && p.name.trim())
185-
.slice(0, max)
186-
.map((p) => ({
187-
name: (p.name as string).trim(),
188-
role: p.role?.toString().trim() || null,
189-
headline: p.headline?.toString().trim() || null,
190-
linkedin_url: p.linkedin_url?.toString().trim() || null,
191-
bio_url: p.bio_url?.toString().trim() || null,
192-
}));
193-
const reply = {
194-
ok: true,
195-
people,
196-
filtered_note: obj.filtered_note?.toString() ?? '',
197-
source_urls: (obj.source_urls ?? []).map(String).filter(Boolean),
198-
};
199-
if (msg.reply) msg.respond(JSON.stringify(reply));
200-
} else {
201-
const have = new Set(existing.map((u) => u.trim()));
202-
const seen = new Set<string>();
203-
const results = ((Array.isArray(parsed) ? parsed : []) as Partial<CrawlLinkCandidate>[])
204-
.filter((r) => typeof r?.url === 'string' && r.url.trim())
205-
.map((r) => ({
206-
url: (r.url as string).trim(),
207-
kind: r.kind?.toString().trim() || undefined,
208-
name: r.name?.toString().trim() || undefined,
209-
title: r.title?.toString().trim() || (r.url as string).trim(),
210-
content: r.content?.toString().trim() || '',
211-
}))
212-
.filter((r) => {
213-
if (have.has(r.url) || seen.has(r.url)) return false;
214-
seen.add(r.url);
215-
return true;
216-
})
217-
.slice(0, max);
218-
if (msg.reply) {
219-
msg.respond(JSON.stringify({ ok: true, provider: 'didi-crawl', results }));
220-
}
165+
// Spawn, don't await — the loop keeps consuming while crawls run.
166+
void (async () => {
167+
await acquireCrawlSlot();
168+
try {
169+
await handleCrawl(nc, msg as unknown as CrawlMsg);
170+
} finally {
171+
releaseCrawlSlot();
221172
}
222-
console.log(JSON.stringify({
223-
level: 'info',
224-
msg: 'crawl completed',
225-
org_slug: args.org_slug,
226-
target: args.target,
227-
ms: Date.now() - started,
173+
})();
174+
}
175+
})();
176+
}
177+
178+
async function handleCrawl(nc: NatsConnection, msg: CrawlMsg): Promise<void> {
179+
const args = msg.json() as CrawlInput;
180+
const started = Date.now();
181+
console.log(JSON.stringify({ level: 'info', msg: 'crawl started', ...args }));
182+
try {
183+
if (!args.org_slug?.trim()) throw new Error('organization.crawl: org_slug is required');
184+
if (!['links', 'streams', 'team'].includes(args.target)) {
185+
throw new Error(`organization.crawl: unknown target ${String(args.target)}`);
186+
}
187+
const max = Math.min(Math.max(args.max_results ?? 12, 1), 25);
188+
189+
const detail = await natsJson<{ ok: boolean; org?: OrgDetail; error?: string }>(
190+
nc,
191+
'organization.detail.requested',
192+
{ org_slug: args.org_slug, client: args.client },
193+
);
194+
if (!detail.ok || !detail.org) throw new Error(detail.error || 'organization.detail failed');
195+
const org = detail.org;
196+
197+
const briefReply = await natsJson<{ ok: boolean; brief?: string | null }>(
198+
nc,
199+
'client.brief.get.requested',
200+
{ client: args.client },
201+
);
202+
const brief = briefReply.ok ? (briefReply.brief ?? null) : null;
203+
204+
const existing =
205+
args.target === 'streams'
206+
? (org.media_streams ?? []).map((e) => e?.url ?? '').filter(Boolean)
207+
: (org.org_links ?? []).map((e) => e?.url ?? '').filter(Boolean);
208+
209+
const request = buildRequest(promptFor(args.target, org, existing, brief, max), {
210+
model: CRAWL_MODEL,
211+
maxTokens: CRAWL_MAX_TOKENS,
212+
tools: ['web_search'],
213+
});
214+
const text = await runPrompt(request);
215+
const parsed = extractJson(text);
216+
217+
if (args.target === 'team') {
218+
const obj = (parsed ?? {}) as {
219+
source_urls?: unknown[];
220+
people?: Partial<CrawlPerson>[];
221+
filtered_note?: string;
222+
};
223+
const people: CrawlPerson[] = (obj.people ?? [])
224+
.filter((p) => typeof p?.name === 'string' && p.name.trim())
225+
.slice(0, max)
226+
.map((p) => ({
227+
name: (p.name as string).trim(),
228+
role: p.role?.toString().trim() || null,
229+
headline: p.headline?.toString().trim() || null,
230+
linkedin_url: p.linkedin_url?.toString().trim() || null,
231+
bio_url: p.bio_url?.toString().trim() || null,
228232
}));
229-
} catch (err: unknown) {
230-
const error = describeError(err);
231-
console.error(JSON.stringify({ level: 'error', msg: 'crawl failed', error }));
232-
if (msg.reply) msg.respond(JSON.stringify({ ok: false, error }));
233+
const reply = {
234+
ok: true,
235+
people,
236+
filtered_note: obj.filtered_note?.toString() ?? '',
237+
source_urls: (obj.source_urls ?? []).map(String).filter(Boolean),
238+
};
239+
if (msg.reply) msg.respond(JSON.stringify(reply));
240+
} else {
241+
const have = new Set(existing.map((u) => u.trim()));
242+
const seen = new Set<string>();
243+
const results = ((Array.isArray(parsed) ? parsed : []) as Partial<CrawlLinkCandidate>[])
244+
.filter((r) => typeof r?.url === 'string' && r.url.trim())
245+
.map((r) => ({
246+
url: (r.url as string).trim(),
247+
kind: r.kind?.toString().trim() || undefined,
248+
name: r.name?.toString().trim() || undefined,
249+
title: r.title?.toString().trim() || (r.url as string).trim(),
250+
content: r.content?.toString().trim() || '',
251+
}))
252+
.filter((r) => {
253+
if (have.has(r.url) || seen.has(r.url)) return false;
254+
seen.add(r.url);
255+
return true;
256+
})
257+
.slice(0, max);
258+
if (msg.reply) {
259+
msg.respond(JSON.stringify({ ok: true, provider: 'didi-crawl', results }));
233260
}
234261
}
235-
})();
262+
console.log(JSON.stringify({
263+
level: 'info',
264+
msg: 'crawl completed',
265+
org_slug: args.org_slug,
266+
target: args.target,
267+
ms: Date.now() - started,
268+
}));
269+
} catch (err: unknown) {
270+
const error = describeError(err);
271+
console.error(JSON.stringify({ level: 'error', msg: 'crawl failed', error }));
272+
if (msg.reply) msg.respond(JSON.stringify({ ok: false, error }));
273+
}
236274
}

0 commit comments

Comments
 (0)