Skip to content

Commit f61a5b3

Browse files
mpstatonclaude
andcommitted
feat(workspace, search-results, org-workbench, shell): searches become async jobs — the queue rail lands
Agent searches (didi's links/streams/team crawls) ran as synchronous invokes: the button disabled, one column was hijacked, and the operator babysat a 60-210s wait per search. Now a search is a job — search.submit returns a search_id in milliseconds, the workspace service owns a durable registry, completion broadcasts over the existing WS event machinery, and a new search-results remote renders the registry as a queue of cards in a persistent right rail. Fire many, walk away, triage on arrival. Registry + async execution (services/workspace): - searches.ts — in-memory map + write-through JSON at SEARCH_STORE_PATH (the sessions/active-workspace volume precedent), 24h TTL sweep, and an executor that dispatches the existing organization.crawl.requested NATS request (same 600s budget) without awaiting it. prompt-runner untouched. - Four local capabilities: search.submit / list / results / dismiss; search.updated joins the WS broadcast subjects. Stranded queued/running entries are marked failed-with-retry on restart, never left spinning. - Local capabilities now receive the actor envelope, so didi attribution survives the async boundary into the crawl request. The rail (apps/search-results, :3018 — spec said 3017 but strategy-curator had claimed it): - Cards show target chip, org, status, and elapsed vs typical duration (hardcoded v1: links/streams ~90s, team ~200s); done cards pulse and count into a header badge; the registry refetches on events, no polling. - Expand fetches results on demand: links/streams rows with a ➕ that carries the crawl's kind (and stream name) through the write; a team surface with the full staged-people candidate gates, consume-on-accept. - Dismissing a done card with unreviewed candidates asks once inline; failed cards offer Retry (resubmit same entity+target). Door switchover (org-workbench, search-and-add, shell): - The links/streams 🤖 and the People 🤖 now enqueue via search.submit and flip the rail visible (augment-it:search-submitted); StagedPeople and crawlTeam are deleted from org-workbench (the rail owns staging now). - search-and-add's crawl mode and the envelope crawl flag are retired; term-search and stream-scan modes unchanged. - Shell: 🔎 queue toggle + done-count badge beside 💬 chat, right-rail slot mirroring the chat rail; searchResults federation entry; dev.sh lists :3018. Proven live against the Aspen safe target: prove-search-queue.mjs all green over the real WS (submit 4ms → running + settle events → 7 results → dismiss), then a browser drive of the spec's click-path — two concurrent crawls ticking in the rail, survival across a page reload, expand, one accept with kind carry-through, and the unreviewed-candidates confirm. Files changed: see the changelog entry changelog/2026-07-24_07_Searches-Become-Async-Jobs-The-Queue-Rail-Lands- Fire-Many-Walk-Away-Triage-On-Arrival.md for the full inventory. Also included: - .gitignore: .playwright-mcp/ browser-drive session artifacts - spec annotated: port correction + status (Phases 1-3 implemented) Closes #42. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013u3i9BoKeZRndqToQ3M5Q5
1 parent b3d1247 commit f61a5b3

36 files changed

Lines changed: 1803 additions & 395 deletions

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,3 +50,6 @@ pnpm-debug.log*
5050
# Local agent + ad-hoc data backups
5151
.claude/
5252
.backups/
53+
54+
# Playwright MCP browser-drive artifacts (session snapshots/logs)
55+
.playwright-mcp/

apps/org-workbench/src/OrgCard.svelte

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import PeopleReveal from './PeopleReveal.svelte';
1010
import { addOrgLink, addOrgStream, updateOrgStream, addOrgCorpus } from './lib/org-client';
1111
import { requestSearch } from './lib/search-request';
12+
import { submitCrawl } from './lib/search-queue';
1213
import type { OrgDetail, SearchRequestDetail, StreamEntry } from './lib/types';
1314
1415
let {
@@ -59,16 +60,16 @@
5960
});
6061
}
6162
62-
// 🤖 — didi's crawl for a whole list (v1.2): same envelope, crawl flag on;
63-
// search-and-add's crawl mode fires organization.crawl instead of a term.
63+
// 🤖 — didi's crawl for a whole list, enqueued as an async job: the search
64+
// lands as a card in the search-results rail (which the shell flips
65+
// visible), no column hijack, no babysitting. Replaces the v1.2
66+
// search-and-add crawl mode per the Search-Results-Queue-Remote spec.
6467
function makeCrawl(target: 'links' | 'streams') {
65-
return () =>
66-
requestSearch({
67-
entity: { type: 'organization', org_slug: org.slug, display_name: displayName },
68-
target,
69-
seed_term: '',
70-
crawl: true,
71-
});
68+
return () => {
69+
void submitCrawl({ org_slug: org.slug, display_name: displayName, target, client }).catch(
70+
(err) => console.warn('[org-workbench] search.submit failed', err),
71+
);
72+
};
7273
}
7374
</script>
7475

apps/org-workbench/src/PeopleReveal.svelte

Lines changed: 20 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@
99
import { onMount } from 'svelte';
1010
import PersonCard from './PersonCard.svelte';
1111
import AddPersonInline from './AddPersonInline.svelte';
12-
import StagedPeople from './StagedPeople.svelte';
13-
import { fetchOrgAffiliations, crawlTeam, type CrawledPerson } from './lib/org-client';
12+
import { fetchOrgAffiliations } from './lib/org-client';
13+
import { submitCrawl } from './lib/search-queue';
1414
import type { AffiliatedPerson } from './lib/types';
1515
1616
let {
@@ -30,28 +30,21 @@
3030
let error = $state<string | null>(null);
3131
let expanded = $state<string | null>(null); // person_uuid
3232
33-
// didi's team crawl (v1.2) — staged candidates, never auto-written.
34-
let crawling = $state(false);
33+
// didi's team crawl — enqueued as an async job; candidates land as a card
34+
// in the search-results rail (staging + accept gates live there now, per
35+
// the Search-Results-Queue-Remote spec). The transient note is the only
36+
// local feedback the door needs.
37+
let queued = $state(false);
3538
let crawlError = $state<string | null>(null);
36-
let crawlGen = $state(0); // bumps per crawl so StagedPeople remounts fresh
37-
let staged = $state<{
38-
people: CrawledPerson[];
39-
filtered_note: string;
40-
source_urls: string[];
41-
} | null>(null);
4239
4340
async function crawl() {
44-
crawling = true;
4541
crawlError = null;
4642
try {
47-
const r = await crawlTeam(org_slug, client);
48-
staged = r;
49-
crawlGen += 1;
50-
if (!open) toggle();
43+
await submitCrawl({ org_slug, display_name: orgName, target: 'team', client });
44+
queued = true;
45+
setTimeout(() => (queued = false), 5_000);
5146
} catch (err) {
5247
crawlError = err instanceof Error ? err.message : String(err);
53-
} finally {
54-
crawling = false;
5548
}
5649
}
5750
@@ -74,10 +67,15 @@
7467
}
7568
7669
function onEntityUpdated(e: Event) {
77-
const detail = (e as CustomEvent).detail as { person_uuid?: string } | undefined;
78-
if (detail?.person_uuid && loaded && people.some((p) => p.person_uuid === detail.person_uuid)) {
70+
const detail = (e as CustomEvent).detail as { person_uuid?: string; org_slug?: string } | undefined;
71+
if (!loaded) return;
72+
if (detail?.person_uuid && people.some((p) => p.person_uuid === detail.person_uuid)) {
7973
void load();
74+
return;
8075
}
76+
// Org-shaped events cover the search rail's team accepts (TeamAccept
77+
// writes person + affiliation, then broadcasts with the org_slug).
78+
if (detail?.org_slug && detail.org_slug === org_slug) void load();
8179
}
8280
8381
// A new org card means fresh people — reset and lazy-load on next reveal.
@@ -86,7 +84,6 @@
8684
people = [];
8785
loaded = false;
8886
expanded = null;
89-
staged = null;
9087
crawlError = null;
9188
if (open) void load();
9289
});
@@ -108,15 +105,14 @@
108105
<button
109106
type="button"
110107
class="ow-plus"
111-
title="didi: crawl the web for relevant team members (selection per the relevance brief)"
112-
disabled={crawling}
108+
title="didi: crawl the web for relevant team members (selection per the relevance brief) — lands in the search queue"
113109
onclick={crawl}
114110
>
115-
{crawling ? '' : '🤖'}
111+
🤖
116112
</button>
117113
</span>
118114
</header>
119-
{#if crawling}<p class="ow-empty">didi is crawling for team members — this takes a minute…</p>{/if}
115+
{#if queued}<p class="ow-empty">team search queued — it lands in the 🔎 search rail when done; keep working</p>{/if}
120116
{#if crawlError}<div class="ow-error">{crawlError}</div>{/if}
121117

122118
{#if open}
@@ -151,20 +147,6 @@
151147
{/each}
152148
</ul>
153149
{/if}
154-
{#if staged}
155-
{#key crawlGen}
156-
<StagedPeople
157-
{org_slug}
158-
{orgName}
159-
{client}
160-
people={staged.people}
161-
filtered_note={staged.filtered_note}
162-
source_urls={staged.source_urls}
163-
onchanged={load}
164-
onclear={() => (staged = null)}
165-
/>
166-
{/key}
167-
{/if}
168150
<AddPersonInline {org_slug} {orgName} {client} onadded={load} />
169151
{/if}
170152
{/if}

apps/org-workbench/src/StagedPeople.svelte

Lines changed: 0 additions & 200 deletions
This file was deleted.

0 commit comments

Comments
 (0)