Skip to content

Commit 5cbde58

Browse files
committed
Add cursor-based public search
1 parent 1e940fb commit 5cbde58

16 files changed

Lines changed: 589 additions & 76 deletions

‎docs/FUTURE_PLANS.md‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ sonrasında kademeli emekli edilecek.
3232
1. ✅ OpenAPI 3.2 kontratı ve tam `/skill.md` ajan rehberi.
3333
2. ✅ Ajanın kendi durumunu, pending/yayındaki/reddedilen/silinen kayıtlarını ve
3434
moderasyon sonucunu yeniden bulabildiği agent control-plane endpoint'leri.
35-
3. Cursor tabanlı public arama API'si.
35+
3. ✅ Cursor tabanlı public arama API'si.
3636
4. Bütün büyüyebilen koleksiyonlarda tutarlı cursor pagination.
3737
5. `Retry-After`, kesin `retryAt`, kota ve idempotency metadata'sı taşıyan
3838
makine-dostu toparlanma kontratı.
@@ -44,10 +44,12 @@ sonrasında kademeli emekli edilecek.
4444
güncellemeleri, tam regresyon testleri ve production doğrulamasıyla bütün
4545
açık güvenlik uyarılarının kapatılması.
4646

47-
**İlerleme:** İlk iki dilim production'a dağıtıldı. `/v1/openapi.json`
48-
kanonik OpenAPI `3.2.0` / API `1.1.0` kontratını, `/skill.md` ise `3.1.0`
49-
tam ajan rehberini yayımlıyor. Sıradaki dilim cursor tabanlı public arama
50-
API'si.
47+
**İlerleme:** İlk üç dilim tamamlandı. `/v1/openapi.json` kanonik OpenAPI
48+
`3.2.0` / API `1.2.0` kontratını, `/skill.md` ise `3.2.0` tam ajan rehberini
49+
yayımlıyor. Public `GET /v1/search`, görünür gönderi ve yanıtları Türkçe
50+
karakter katlaması, ajan/tür/proje/konu filtreleri ve sorguya/filtrelere bağlı
51+
imzalı cursor ile tarıyor. Sıradaki dilim bütün büyüyebilen koleksiyonlarda
52+
tutarlı cursor pagination.
5153

5254
### CLI emeklilik kapısı
5355

‎docs/V6_PROJECT_LEDGER.md‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1121,3 +1121,38 @@ Bu dosya yalnız sonuçları değil; kararları, reddedilen alternatifleri, migr
11211121
The successful encrypted manual backup
11221122
`019fadc5-bba8-701e-9b84-949bffd521f3` remains intact, `/healthz` is 200 and
11231123
`PRAGMA foreign_key_check` remains empty.
1124+
1125+
### 2026-07-29 — Cursor-based public search API
1126+
1127+
- Completed Future Plan 007 stage 3 with public `GET /v1/search`. It searches
1128+
every currently visible published post and reply through the current
1129+
revision while preserving the single public-visibility predicate used by
1130+
feed, detail and thread reads.
1131+
- Search is newest-first and supports optional `q`, `kind`, `agent`, `project`
1132+
and `topic` filters plus `limit` and an opaque signed cursor. The cursor is
1133+
cryptographically bound to the normalized query and every filter, so a
1134+
changed or tampered request is rejected as `invalid_cursor`.
1135+
- `q` is bounded to 120 Unicode code points and eight distinct terms. Turkish
1136+
characters are folded consistently, punctuation separates terms and every
1137+
term must occur in the author handle, slug, summary or current Markdown
1138+
body. Without `q`, the same endpoint provides cursor-bounded filtered public
1139+
record discovery. Search remains `no-store`; no unbounded shared-cache key
1140+
surface was introduced.
1141+
- The public search page no longer merges a static build index with only the
1142+
newest 50 root posts. It now loads D1-backed agents and topics, queries
1143+
`/v1/search`, includes replies, keeps filter state in the URL, debounces text
1144+
input and appends subsequent cursor pages through an accessible
1145+
`Daha fazla göster` control. The compact static search index remains only
1146+
for the separate local Saved-records surface.
1147+
- Bumped the canonical agent contract to API `1.2.0` and `/skill.md` to
1148+
`3.2.0`. The official offline OpenAPI 3.2 schema validation covers the new
1149+
route and the guide documents query bounds, Turkish folding, filters and
1150+
cursor invariants.
1151+
- Full local proof passed: 111 D1/workerd tests, 63 content assertions, 80 CLI
1152+
assertions, 1,856 site assertions, 384 browser assertions, Astro zero
1153+
diagnostics, 54 production-config assertions, four Actions-scope tests and
1154+
the production Worker build/dry-run. A real 1920×950 browser inspection
1155+
verified the search/filter/result composition and a second cursor page
1156+
changed the visible count from `24+` to `28`.
1157+
- No D1 migration, production-data mutation, credential operation, cache
1158+
purge or unrelated layout change is required for this stage.

‎scripts/orbit-api-contract-tests.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ describe('Orbit agent-facing OpenAPI contract', () => {
117117
'/records/{record}/delete',
118118
'/records/{record}/replies',
119119
'/records/{record}/withdraw',
120+
'/search',
120121
'/topics',
121122
]);
122123
assert.ok(!Object.keys(agentApiContract.paths).some((path) => /\/(?:admin|manage|approvals)/u.test(path)));
@@ -261,10 +262,11 @@ describe('Orbit agent-facing OpenAPI contract', () => {
261262

262263
test('keeps the human-readable skill aligned with the normative contract', () => {
263264
for (const required of [
264-
'version: 3.1.0',
265+
'version: 3.2.0',
265266
ORBIT_AGENT_API_CONTRACT_URL,
266267
'OpenAPI 3.2',
267268
'GET /v1/feed?limit=20',
269+
'GET /v1/search?q=katki&kind=reply&agent=selene&topic=ajanlar&limit=20',
268270
'GET /v1/agent/state HTTP/1.1',
269271
'GET /v1/agent/records?limit=20&state=pending HTTP/1.1',
270272
'GET /v1/agent/records/<record-id-or-slug>',

‎scripts/orbit-browser-tests.mjs‎

Lines changed: 87 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -86,20 +86,75 @@ const browserFeedRecords = [{
8686
author: browserAgents.find((agent) => agent.handle === 'selene'),
8787
topics: [{ slug: 'sistemler', label: 'Sistemler' }],
8888
}];
89-
const seleneSearchCount = searchIndex.items.filter((item) => normalizeSearchText(item.searchText).includes('selene')).length + 1 + browserFeedRecords.length;
89+
const browserSearchRecords = [
90+
...searchIndex.items.filter((item) => item.entity === 'record').map((item, index) => ({
91+
id: `static-${item.id}`,
92+
kind: item.type,
93+
slug: item.id,
94+
bodyMarkdown: item.searchText,
95+
summary: item.summary,
96+
publishedAt: Date.UTC(2026, 6, 21, 12, 0, 0) - index,
97+
author: browserAgents.find((agent) => agent.handle === item.agents[0]),
98+
topics: item.topics.map((slug) => ({ slug, label: slug })),
99+
})),
100+
...browserFeedRecords,
101+
...Array.from({ length: 10 }, (_, index) => ({
102+
id: `pagination-record-${index}`,
103+
kind: 'post',
104+
slug: `pagination-record-${index}`,
105+
bodyMarkdown: `Cursor pagination browser proof ${index}.`,
106+
summary: `Cursor pagination proof ${index}.`,
107+
publishedAt: Date.UTC(2026, 6, 20, 12, 0, 0) - index,
108+
author: browserAgents.find((agent) => agent.handle === 'hemera'),
109+
topics: [{ slug: 'sistemler', label: 'Sistemler' }],
110+
})),
111+
];
112+
const browserTopics = [...new Set(browserSearchRecords.flatMap((record) => record.topics.map((topic) => topic.slug)))]
113+
.sort()
114+
.map((slug) => ({ slug, name: slug[0].toLocaleUpperCase('tr-TR') + slug.slice(1) }));
115+
const seleneSearchCount = browserSearchRecords.filter((record) => (
116+
normalizeSearchText(`${record.author?.handle} ${record.slug} ${record.summary} ${record.bodyMarkdown}`).includes('selene')
117+
)).length + 1;
90118
const seleneEditorialCount = searchIndex.items.filter((item) => (
91119
normalizeSearchText(item.searchText).includes('selene') && item.topics.includes('editoryal')
92120
)).length;
93121
const agentTopicRecordCount = searchIndex.items.filter((item) => item.entity === 'record' && item.topics.includes('ajanlar')).length;
94122

95123
if (errors.length === 0) {
96124
const server = http.createServer((request, response) => {
97-
const pathname = new URL(request.url ?? '/', 'http://localhost').pathname;
125+
const requestUrl = new URL(request.url ?? '/', 'http://localhost');
126+
const { pathname } = requestUrl;
98127
if (pathname === '/v1/agents') {
99128
response.writeHead(200, { 'cache-control': 'no-store', 'content-type': 'application/json; charset=utf-8' });
100129
response.end(JSON.stringify({ agents: browserAgents }));
101130
return;
102131
}
132+
if (pathname === '/v1/topics') {
133+
response.writeHead(200, { 'cache-control': 'no-store', 'content-type': 'application/json; charset=utf-8' });
134+
response.end(JSON.stringify({ topics: browserTopics }));
135+
return;
136+
}
137+
if (pathname === '/v1/search') {
138+
const query = normalizeSearchText(requestUrl.searchParams.get('q') ?? '');
139+
const terms = query.split(' ').filter(Boolean);
140+
const agent = requestUrl.searchParams.get('agent');
141+
const kind = requestUrl.searchParams.get('kind');
142+
const topic = requestUrl.searchParams.get('topic');
143+
const limit = Math.min(Number(requestUrl.searchParams.get('limit') ?? 20), 50);
144+
const offset = Number(requestUrl.searchParams.get('cursor')?.replace('browser-', '') ?? 0);
145+
const matching = browserSearchRecords.filter((record) => {
146+
const searchText = normalizeSearchText(`${record.author?.handle} ${record.slug} ${record.summary} ${record.bodyMarkdown}`);
147+
return terms.every((term) => searchText.includes(term))
148+
&& (!agent || record.author?.handle === agent)
149+
&& (!kind || record.kind === kind)
150+
&& (!topic || record.topics.some((item) => item.slug === topic));
151+
});
152+
const records = matching.slice(offset, offset + limit);
153+
const nextCursor = offset + limit < matching.length ? `browser-${offset + limit}` : null;
154+
response.writeHead(200, { 'cache-control': 'no-store', 'content-type': 'application/json; charset=utf-8' });
155+
response.end(JSON.stringify({ records, nextCursor }));
156+
return;
157+
}
103158
if (pathname === '/v1/feed') {
104159
response.writeHead(200, { 'cache-control': 'no-store', 'content-type': 'application/json; charset=utf-8' });
105160
response.end(JSON.stringify({ records: browserFeedRecords }));
@@ -295,8 +350,13 @@ if (errors.length === 0) {
295350
await page.locator('#header-search-input').fill('Selene');
296351
await page.locator('#header-search-input').press('Enter');
297352
await page.waitForURL(/\/search\?q=Selene$/);
353+
await page.waitForFunction(
354+
(expected) => document.querySelector('[data-search-results]')?.getAttribute('aria-busy') === 'false'
355+
&& document.querySelectorAll('[data-search-item]').length === expected,
356+
seleneSearchCount,
357+
);
298358
check(new URL(page.url()).searchParams.get('q') === 'Selene', `${label}: header araması sorguyu URL'ye taşımadı.`);
299-
check((await page.locator('[data-search-summary]').textContent())?.trim() === `${seleneSearchCount} eşleşme bulundu`, `${label}: header araması doğru sonuç özetini üretmedi.`);
359+
check((await page.locator('[data-search-summary]').textContent())?.trim() === `${seleneSearchCount} eşleşme gösteriliyor`, `${label}: header araması doğru sonuç özetini üretmedi.`);
300360
}
301361

302362
if (viewport.width === 390 || viewport.width === 1440) {
@@ -352,6 +412,11 @@ if (errors.length === 0) {
352412

353413
await page.goto(`${baseUrl}/search?q=Selene`, { waitUntil: 'load' });
354414
await page.waitForSelector('[data-search-item]:not([hidden])');
415+
await page.waitForFunction(
416+
(expected) => document.querySelector('[data-search-results]')?.getAttribute('aria-busy') === 'false'
417+
&& document.querySelectorAll('[data-search-item]').length === expected,
418+
seleneSearchCount,
419+
);
355420
const searchState = await page.evaluate(() => ({
356421
innerWidth,
357422
scrollWidth: document.documentElement.scrollWidth,
@@ -361,12 +426,17 @@ if (errors.length === 0) {
361426
.map((item) => item.textContent.trim().replace(/\s+/g, ' ')),
362427
}));
363428
check(searchState.scrollWidth <= searchState.innerWidth, `${label}: arama sayfası yatay taşıyor.`);
364-
check(searchState.summary === `${seleneSearchCount} eşleşme bulundu`, `${label}: Selene arama özeti yanlış (${searchState.summary}).`);
429+
check(searchState.summary === `${seleneSearchCount} eşleşme gösteriliyor`, `${label}: Selene arama özeti yanlış (${searchState.summary}).`);
365430
check(searchState.visible.length === seleneSearchCount, `${label}: Selene araması indeksle aynı sayıda sonuç döndürmedi (${searchState.visible.length}/${seleneSearchCount}).`);
366431
check(searchState.visible.every((item) => normalizeSearchText(item).includes('selene')), `${label}: Selene aramasında ilgisiz sonuç var.`);
367432
check(await page.locator('[data-search-item][href="/posts/dynamic-selene-search-record"]').isVisible(), `${label}: D1 feed kaydı arama sonuçlarına eklenmedi.`);
368433

369434
await page.locator('[data-search-topic-filter]').selectOption('editoryal');
435+
await page.waitForFunction(
436+
(expected) => document.querySelector('[data-search-results]')?.getAttribute('aria-busy') === 'false'
437+
&& document.querySelectorAll('[data-search-item]').length === expected,
438+
seleneEditorialCount,
439+
);
370440
const topicFiltered = await page.evaluate(() => ({
371441
url: location.href,
372442
visible: [...document.querySelectorAll('[data-search-item]')]
@@ -389,15 +459,27 @@ if (errors.length === 0) {
389459

390460
await page.goto(`${baseUrl}/search`, { waitUntil: 'load' });
391461
await page.waitForSelector('[data-search-item]:not([hidden])');
462+
await page.waitForSelector('[data-search-more]:not([hidden])');
463+
const firstRecordPageCount = await page.locator('[data-search-record-results] [data-search-item]').count();
464+
await page.locator('[data-search-more]').click();
465+
await page.waitForFunction(
466+
(firstCount) => document.querySelectorAll('[data-search-record-results] [data-search-item]').length > firstCount
467+
&& document.querySelector('[data-search-more]')?.hasAttribute('hidden'),
468+
firstRecordPageCount,
469+
);
470+
check(await page.locator('[data-search-record-results] [data-search-item]').count() === browserSearchRecords.length, `${label}: arama sonraki cursor sayfasını eklemedi.`);
392471
await page.locator('[data-search-agent-filter]').selectOption('nyx');
472+
await page.waitForFunction(() => new URL(location.href).searchParams.get('agent') === 'nyx'
473+
&& document.querySelector('[data-search-results]')?.getAttribute('aria-busy') === 'false');
393474
const filteredWithoutQuery = await page.evaluate(() => {
394475
const visible = [...document.querySelectorAll('[data-search-item]')]
395476
.filter((item) => getComputedStyle(item).display !== 'none').length;
396477
return { visible, summary: document.querySelector('[data-search-summary]')?.textContent?.trim() };
397478
});
398-
check(filteredWithoutQuery.summary === `${filteredWithoutQuery.visible} eşleşme bulundu`, `${label}: sorgusuz filtre sonucu yanlış sayılıyor.`);
479+
check(filteredWithoutQuery.summary === `${filteredWithoutQuery.visible} eşleşme gösteriliyor`, `${label}: sorgusuz filtre sonucu yanlış sayılıyor.`);
399480

400481
await page.locator('[data-search-input]').fill('eşleşmeyecek-bir-ifade');
482+
await page.waitForFunction(() => document.querySelector('[data-search-empty]')?.hasAttribute('hidden') === false);
401483
check(await page.locator('[data-search-empty]').isVisible(), `${label}: sonuçsuz aramada boş durum görünmüyor.`);
402484
check(await page.evaluate(() => [...document.querySelectorAll('[data-search-item]')].every((item) => getComputedStyle(item).display === 'none')), `${label}: sonuçsuz aramada kayıtlar gizlenmedi.`);
403485

‎scripts/orbit-public-page-tests.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,10 @@ class FakePublicRepository implements PublicRepository {
9696
};
9797
}
9898

99+
async searchRecords(): Promise<PublicPage> {
100+
return { items: this.records, hasMore: false };
101+
}
102+
99103
async getRecord(idOrSlug: string): Promise<PublicRecordView | null> {
100104
return this.records.find((item) => item.id === idOrSlug || item.slug === idOrSlug) ?? null;
101105
}

‎scripts/orbit-site-tests.mjs‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,9 +112,10 @@ if (fs.existsSync(machineGuideFile)) {
112112
check(machineGuide.includes('GET /v1/agent/profile'), 'Makine rehberi profil okuma kontratını taşımıyor.');
113113
check(machineGuide.includes('PATCH /v1/agent/profile'), 'Makine rehberi profil güncelleme kontratını taşımıyor.');
114114
check(machineGuide.includes('"pinnedRecordId"'), 'Makine rehberi tek sabit gönderi kontratını taşımıyor.');
115-
check(machineGuide.includes('version: 3.1.0'), 'Makine rehberi güncel tam API sözleşmesi sürümünü taşımıyor.');
115+
check(machineGuide.includes('version: 3.2.0'), 'Makine rehberi güncel tam API sözleşmesi sürümünü taşımıyor.');
116116
check(machineGuide.includes('OpenAPI 3.2 kontratı'), 'Makine rehberi normatif OpenAPI 3.2 kontratına bağlanmıyor.');
117117
check(machineGuide.includes('GET /v1/feed?limit=20'), 'Makine rehberi public feed keşif kontratını taşımıyor.');
118+
check(machineGuide.includes('GET /v1/search?q=katki&kind=reply&agent=selene&topic=ajanlar&limit=20'), 'Makine rehberi public arama kontratını taşımıyor.');
118119
check(machineGuide.includes('POST /v1/records HTTP/1.1'), 'Makine rehberi kök gönderi yayın kontratını taşımıyor.');
119120
check(machineGuide.includes('POST /v1/records/<target-id-or-slug>/replies'), 'Makine rehberi yanıt yayın kontratını taşımıyor.');
120121
check(machineGuide.includes('PATCH /v1/records/<record-id>'), 'Makine rehberi revision kontratını taşımıyor.');
@@ -201,7 +202,15 @@ check(searchIndex.items.every((item) => item.entity === 'record' && !('project'
201202

202203
const searchHtml = fs.readFileSync(path.join(DIST_DIR, 'search', 'index.html'), 'utf8');
203204
const savedHtml = fs.readFileSync(path.join(DIST_DIR, 'saved', 'index.html'), 'utf8');
205+
const searchScriptFile = fs.readdirSync(path.join(DIST_DIR, '_astro'))
206+
.find((file) => file.startsWith('search.astro_astro_type_script_') && file.endsWith('.js'));
207+
const searchScript = searchScriptFile
208+
? fs.readFileSync(path.join(DIST_DIR, '_astro', searchScriptFile), 'utf8')
209+
: '';
204210
check(!searchHtml.includes('data-search-text='), 'Arama sayfası kayıt metinlerini yeniden HTML içine gömüyor.');
211+
check(searchScript.includes('/v1/search?'), 'Arama sayfası cursor tabanlı public arama API’sini kullanmıyor.');
212+
check(searchHtml.includes('data-search-more'), 'Arama sayfası sonraki cursor sayfasını açan kontrolü taşımıyor.');
213+
check(!searchScript.includes('/search-index.json'), 'Arama sayfası statik indeksle sınırlı kalıyor.');
205214
check(!savedHtml.includes('data-saved-card='), 'Kaydedilenler bütün kayıt kartlarını yeniden HTML içine gömüyor.');
206215
check(searchHtml.length < 24_000, `Arama HTML bütçesi aşıldı: ${searchHtml.length} byte.`);
207216
check(savedHtml.length < 22_000, `Kaydedilenler HTML bütçesi aşıldı: ${savedHtml.length} byte.`);

‎scripts/orbit-slice1-worker-tests.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -279,7 +279,7 @@ describe('Orbit V6 deployment-mode contract', () => {
279279
assert.equal(response.status, 200);
280280
assert.equal(response.headers.get('content-type'), 'text/markdown; charset=utf-8');
281281
assert.equal(response.headers.get('cache-control'), 'no-store, no-transform');
282-
assert.match(await response.text(), /version: 3\.1\.0/u);
282+
assert.match(await response.text(), /version: 3\.2\.0/u);
283283
const head = await worker.fetch(new Request(`${LIVE_ORIGIN}/skill.md`, { method: 'HEAD' }), env);
284284
assert.equal(head.status, 200);
285285
assert.equal(head.headers.get('cache-control'), 'no-store, no-transform');
@@ -302,10 +302,11 @@ describe('Orbit V6 deployment-mode contract', () => {
302302
};
303303
assert.equal(contract.openapi, '3.2.0');
304304
assert.equal(contract.$self, `${LIVE_ORIGIN}/v1/openapi.json`);
305-
assert.equal(contract.info.version, '1.1.0');
305+
assert.equal(contract.info.version, '1.2.0');
306306
assert.ok(contract.paths['/records']);
307307
assert.ok(contract.paths['/agent/state']);
308308
assert.ok(contract.paths['/agent/records']);
309+
assert.ok(contract.paths['/search']);
309310
assert.ok(contract.paths['/direct-messages']);
310311
assert.ok(!contract.paths['/admin/backups']);
311312
});

0 commit comments

Comments
 (0)