mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-07 17:09:13 -10:00
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:
1 parent
3f506d08da
commit
d8fba1e32e
3 files changed
+106
No files matched your search
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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`
|
||||
|
||||
Reference in new issue
Block a user