feat(app-server): list descendant threads by ancestor (#29591)

## Why

`thread/list` can filter direct children with `parentThreadId`, but
clients cannot request an entire spawned subtree. Discovering every
descendant requires repeated client-side requests and gives up the
database's existing filtering and pagination path.

## What changed

Experimental clients can use `ancestorThreadId` to return strict
descendants at any depth while `parentThreadId` retains its direct-child
meaning. The filters are mutually exclusive, the ancestor is excluded,
and every result preserves its immediate `parentThreadId` so callers can
reconstruct the tree.

## How it works

- **Explicit relationship:** Internal list parameters distinguish direct
children from transitive descendants without changing the meaning of
`parentThreadId`.
- **Existing graph:** Persisted parent-child spawn edges remain the
source of truth, so descendant lookup needs no schema migration or
ancestry cache.
- **Indexed traversal:** A recursive SQLite query starts from the
parent-edge index, walks each generation, and applies thread filters,
sorting, and cursor pagination in the same database request.
- **Reconstructable results:** The response stays flat and normally
ordered while carrying each descendant's immediate parent.

## Verification

Ran 550 tests across the protocol, state, rollout, and thread-store
crates, then reran the four focused state, store, and app-server
descendant-listing tests after the final diff reduction. Scoped Clippy
and formatting checks passed. Stable and experimental schema generation
was checked; the stable fixtures remain unchanged while the experimental
schema includes the new field.
This commit is contained in:
Brent Traut authored and GitHub committed 2026-06-24 13:08:14 -07:00
1 parent 3ccef20ef4
commit 8057603d0c
30 files changed
+494 -106

No files matched your search

@@ -1088,10 +1088,15 @@ pub struct ThreadListParams {
/// Optional substring filter for the extracted thread title.
#[ts(optional = nullable)]
pub search_term: Option<String>,
/// Optional direct parent thread filter.
/// Optional direct parent thread filter. Mutually exclusive with `ancestorThreadId`.
#[experimental("thread/list.parentThreadId")]
#[ts(optional = nullable)]
pub parent_thread_id: Option<String>,
/// Optional ancestor thread filter. Returns spawned descendants at any depth, excluding the
/// ancestor itself. Mutually exclusive with `parentThreadId`.
#[experimental("thread/list.ancestorThreadId")]
#[ts(optional = nullable)]
pub ancestor_thread_id: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]