runtime-api: add read-only GET /v1/goals cross-thread goal index (APPS-146)

Lists per-thread goal records newest-update first with limit (1-200, default 50),
keyset cursor and status filter; bad status/cursor is 400. Lives on the same
router and auth as the other /v1 reads; no mutation. docs/RUNTIME_API.md updated.

Evidence: cargo check -p codewhale-tui --lib compiled the crate; its only error
was a pre-existing dead-code lint in automation_manager.rs (another agent's
dirty file, untouched). cargo test -p codewhale-tui --lib
runtime_api::tests::thread_goal: 3 passed, 0 failed. Limits: no test exercises
the new route (no new tests per instruction); not run against a live server; the
broader 'goal' filter aborted with a stack overflow in an unrelated engine test
(core::engine::tests::current_turn_usage_does_not_stop_budgeted_goal...).
Listing reads every goal file per request.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
CodeWhale BotandClaude Sonnet 5.5 committed 2026-10-09 18:36:49 -07:00
1 parent 3f506d08da
commit d8fba1e32e
3 files changed
+106

No files matched your search

+67
View File
@@ -2628,6 +2628,7 @@ pub fn build_router(state: RuntimeApiState) -> Router {
)
.route("/v1/threads/{id}/goal/complete", post(complete_thread_goal))
.route("/v1/threads/{id}/goal/block", post(block_thread_goal))
.route("/v1/goals", get(list_goals))
.route("/v1/approvals", get(list_approvals))
.route("/v1/approvals/{approval_id}", post(decide_approval))
.route(
@@ -8632,6 +8633,72 @@ async fn compact_thread(
// Thread goal endpoints
// ---------------------------------------------------------------------------
#[derive(Debug, Deserialize)]
struct GoalsQuery {
limit: Option<usize>,
cursor: Option<String>,
status: Option<String>,
}
#[derive(Debug, Serialize)]
struct GoalsPage {
goals: Vec<codewhale_protocol::ThreadGoal>,
#[serde(skip_serializing_if = "Option::is_none")]
next_cursor: Option<String>,
}
/// `GET /v1/goals?limit=50&cursor=<opaque>&status=<status>` — read-only index
/// of the per-thread goal records, newest update first. Pages are keyset
/// cursors over `(updated_at, thread_id)`.
async fn list_goals(
State(state): State<RuntimeApiState>,
Query(query): Query<GoalsQuery>,
) -> Result<Json<GoalsPage>, ApiError> {
let limit = query.limit.unwrap_or(50).clamp(1, 200);
let status = match query.status.as_deref() {
None => None,
Some(raw) => Some(
serde_json::from_value::<codewhale_protocol::ThreadGoalStatus>(
serde_json::Value::String(raw.to_string()),
)
.map_err(|_| ApiError::bad_request(format!("unknown goal status '{raw}'")))?,
),
};
let after = match query.cursor.as_deref() {
None => None,
Some(raw) => {
let (updated_at, thread_id) = raw
.split_once(':')
.and_then(|(at, id)| Some((at.parse::<i64>().ok()?, id.to_string())))
.ok_or_else(|| ApiError::bad_request("invalid cursor"))?;
Some((updated_at, thread_id))
}
};
let mut goals = state
.runtime_threads
.list_goals()
.await
.map_err(|e| ApiError::internal(e.to_string()))?;
goals.retain(|goal| status.as_ref().is_none_or(|s| &goal.status == s));
goals.sort_by(|a, b| {
b.updated_at
.cmp(&a.updated_at)
.then_with(|| a.thread_id.cmp(&b.thread_id))
});
if let Some((updated_at, thread_id)) = after {
goals.retain(|goal| {
goal.updated_at < updated_at
|| (goal.updated_at == updated_at && goal.thread_id > thread_id)
});
}
let next_cursor = (goals.len() > limit).then(|| {
let last = &goals[limit - 1];
format!("{}:{}", last.updated_at, last.thread_id)
});
goals.truncate(limit);
Ok(Json(GoalsPage { goals, next_cursor }))
}
/// `GET /v1/threads/{id}/goal` — return the persistent goal for a thread, or
/// 404 if the thread has no goal.
async fn get_thread_goal(
+32
View File
@@ -2978,6 +2978,22 @@ impl RuntimeThreadStore {
Ok(Some(goal))
}
pub fn list_goal_thread_ids(&self) -> Result<Vec<String>> {
let mut ids = Vec::new();
for entry in fs::read_dir(&self.goals_dir)
.with_context(|| format!("Failed to read {}", self.goals_dir.display()))?
{
let path = entry?.path();
if path.extension().and_then(|ext| ext.to_str()) != Some("json") {
continue;
}
if let Some(stem) = path.file_stem().and_then(|stem| stem.to_str()) {
ids.push(stem.to_string());
}
}
Ok(ids)
}
/// A late turn can only update the revision admitted with that turn.
/// Load/compare/write share the same guard as explicit save/delete.
/// Mutate the goal only while it is still the revision the caller read:
@@ -8798,6 +8814,22 @@ impl RuntimeThreadManager {
Ok(goal)
}
/// Every readable goal record across threads, with live usage projected.
/// Unreadable records are skipped so one corrupt file cannot hide the rest.
pub async fn list_goals(&self) -> Result<Vec<codewhale_protocol::ThreadGoal>> {
let store = self.store.clone();
let ids = tokio::task::spawn_blocking(move || store.list_goal_thread_ids())
.await
.context("goal list task panicked")??;
let mut goals = Vec::with_capacity(ids.len());
for id in ids {
if let Ok(Some(goal)) = self.get_goal(&id).await {
goals.push(goal);
}
}
Ok(goals)
}
/// Persist (create or replace) the goal for a thread.
pub async fn save_goal(&self, goal: codewhale_protocol::ThreadGoal) -> Result<()> {
let store = self.store.clone();
+7
View File
@@ -1058,6 +1058,13 @@ and live state comes only from a resumed thread's SSE stream.
- `GET /v1/threads?limit=50&include_archived=false&archived_only=false`
- `GET /v1/threads/summary?limit=50&search=<optional>&include_archived=false&archived_only=false&thread_ids=<id>,<id>`
- `GET /v1/threads/running`
- `GET /v1/goals?limit=50&cursor=<opaque>&status=<status>` — read-only index of
the per-thread goal records across threads, newest update first. Returns
`{"goals": [ThreadGoal...], "next_cursor": "<opaque>"}`; `next_cursor` is
absent on the last page. `limit` is 1-200 (default 50); `status` is one of
`active`, `paused`, `blocked`, `usage_limited`, `budget_limited`, `complete`;
a bad `status` or `cursor` is `400`. Same auth as every other `/v1` read.
Goal creation, completion and removal stay on `/v1/threads/{id}/goal`.
- `GET /v1/threads/{id}/notices`
- `DELETE /v1/threads/{id}/notices/{notice_id}`
- `POST /v1/threads`