feat(runtime-api): add read-only snapshot listing

Harvest the safe GUI-facing snapshot list slice from PR #2808 without exposing restore, retry, patch-undo, or other runtime mutation endpoints. The endpoint is protected by the existing runtime API token middleware and mirrors the /restore list bound.

Refs #2808, #2580.

Co-authored-by: gaord <9567937+gaord@users.noreply.github.com>
This commit is contained in:
Hunter Bown
2026-06-05 21:11:33 -07:00
committed by GitHub
parent d1f3c65195
commit bb88358122
3 changed files with 104 additions and 12 deletions
+9 -6
View File
@@ -47,12 +47,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
remain deferred until the runtime semantics are safe (#2668).
- Added an official VS Code extension Phase 0 scaffold with terminal launch,
local runtime attach checks, status bar state, and a read-only Agent View
preview backed by recent runtime thread summaries. This answers the VS Code
GUI lane without exposing chat webviews, inline edits, or retry/undo runtime
endpoints yet (#461, #462, #480, #1584, #2580). Thanks @AiurArtanis for the
Agent View prompt, @lbcheng888 for the earlier scaffold, and @BigBenLabs,
@lzx1545642258, @yangdaowan, @mangdehuang, @VerrPower, @hejia-v,
@nasus9527, and @ygzhang-cn for the GUI/VS Code demand and validation trail.
preview backed by recent runtime thread summaries, plus a read-only
`GET /v1/snapshots` endpoint for GUI clients to inspect side-git restore
points. This answers the VS Code GUI lane without exposing chat webviews,
inline edits, or retry/undo/restore runtime mutation endpoints yet (#461,
#462, #480, #1584, #2580, #2808). Thanks @AiurArtanis for the Agent View
prompt, @lbcheng888 for the earlier scaffold, @gaord for the GUI runtime API
direction, and @BigBenLabs, @lzx1545642258, @yangdaowan, @mangdehuang,
@VerrPower, @hejia-v, @nasus9527, and @ygzhang-cn for the GUI/VS Code demand
and validation trail.
- Added a static prompt composer override for embedders that need to replace
the byte-stable base/personality prompt segment while leaving mode metadata,
approval policy, tool taxonomy, Context Management, and the Compaction Relay
+9 -6
View File
@@ -47,12 +47,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
remain deferred until the runtime semantics are safe (#2668).
- Added an official VS Code extension Phase 0 scaffold with terminal launch,
local runtime attach checks, status bar state, and a read-only Agent View
preview backed by recent runtime thread summaries. This answers the VS Code
GUI lane without exposing chat webviews, inline edits, or retry/undo runtime
endpoints yet (#461, #462, #480, #1584, #2580). Thanks @AiurArtanis for the
Agent View prompt, @lbcheng888 for the earlier scaffold, and @BigBenLabs,
@lzx1545642258, @yangdaowan, @mangdehuang, @VerrPower, @hejia-v,
@nasus9527, and @ygzhang-cn for the GUI/VS Code demand and validation trail.
preview backed by recent runtime thread summaries, plus a read-only
`GET /v1/snapshots` endpoint for GUI clients to inspect side-git restore
points. This answers the VS Code GUI lane without exposing chat webviews,
inline edits, or retry/undo/restore runtime mutation endpoints yet (#461,
#462, #480, #1584, #2580, #2808). Thanks @AiurArtanis for the Agent View
prompt, @lbcheng888 for the earlier scaffold, @gaord for the GUI runtime API
direction, and @BigBenLabs, @lzx1545642258, @yangdaowan, @mangdehuang,
@VerrPower, @hejia-v, @nasus9527, and @ygzhang-cn for the GUI/VS Code demand
and validation trail.
- Added a static prompt composer override for embedders that need to replace
the byte-stable base/personality prompt segment while leaving mode metadata,
approval policy, tool taxonomy, Context Management, and the Compaction Relay
+86
View File
@@ -586,6 +586,7 @@ pub fn build_router(state: RuntimeApiState) -> Router {
.route("/v1/automations/{id}/resume", post(resume_automation))
.route("/v1/automations/{id}/runs", get(list_automation_runs))
.route("/v1/usage", get(get_usage))
.route("/v1/snapshots", get(list_snapshots))
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_runtime_token,
@@ -2178,6 +2179,59 @@ async fn get_usage(
Ok(Json(json!(aggregation)))
}
#[derive(Debug, Deserialize)]
struct SnapshotsQuery {
/// Maximum number of snapshots to return. Mirrors `/restore list [N]`.
limit: Option<usize>,
}
#[derive(Debug, Serialize)]
struct SnapshotEntry {
id: String,
label: String,
timestamp: i64,
}
async fn list_snapshots(
State(state): State<RuntimeApiState>,
Query(query): Query<SnapshotsQuery>,
) -> Result<Json<Vec<SnapshotEntry>>, ApiError> {
Ok(Json(snapshot_entries_for_workspace(
&state.workspace,
query,
)?))
}
fn snapshot_entries_for_workspace(
workspace: &FsPath,
query: SnapshotsQuery,
) -> Result<Vec<SnapshotEntry>, ApiError> {
const DEFAULT_LIMIT: usize = 20;
const MAX_LIMIT: usize = 100;
let limit = match query.limit.unwrap_or(DEFAULT_LIMIT) {
1..=MAX_LIMIT => query.limit.unwrap_or(DEFAULT_LIMIT),
other => {
return Err(ApiError::bad_request(format!(
"limit must be between 1 and {MAX_LIMIT}; got {other}",
)));
}
};
let repo = crate::snapshot::SnapshotRepo::open_or_init(workspace)
.map_err(|e| ApiError::internal(format!("Snapshot repo unavailable: {e}")))?;
let snapshots = repo
.list(limit)
.map_err(|e| ApiError::internal(format!("Failed to list snapshots: {e}")))?;
Ok(snapshots
.into_iter()
.map(|snapshot| SnapshotEntry {
id: snapshot.id.as_str().to_string(),
label: snapshot.label,
timestamp: snapshot.timestamp,
})
.collect())
}
const MOBILE_HTML: &str = include_str!("runtime_mobile.html");
/// Built-in dev origins always allowed by the runtime API (whalescale#255).
@@ -2307,6 +2361,7 @@ mod tests {
use crate::core::ops::Op;
use crate::models::Usage;
use crate::runtime_threads::RuntimeEventRecord;
use crate::test_support::{EnvVarGuard, lock_test_env};
use anyhow::{Context, bail};
use futures_util::StreamExt;
use std::fs;
@@ -4075,6 +4130,37 @@ mod tests {
Ok(())
}
#[test]
fn snapshots_endpoint_lists_workspace_snapshots() -> Result<()> {
let _lock = lock_test_env();
let root = tempfile::tempdir()?;
let home = root.path().join("home");
fs::create_dir_all(&home)?;
let _home = EnvVarGuard::set("HOME", &home);
let workspace = root.path().join("workspace");
fs::create_dir_all(&workspace)?;
let repo = crate::snapshot::SnapshotRepo::open_or_init(&workspace)?;
fs::write(workspace.join("a.txt"), "v1")?;
repo.snapshot("pre-turn:1")?;
fs::write(workspace.join("a.txt"), "v2")?;
repo.snapshot("post-turn:1")?;
let snapshots =
snapshot_entries_for_workspace(&workspace, SnapshotsQuery { limit: Some(1) })
.expect("snapshot listing should succeed");
assert_eq!(snapshots.len(), 1);
assert_eq!(snapshots[0].label, "post-turn:1");
assert!(snapshots[0].id.len() >= 8);
assert!(snapshots[0].timestamp > 0);
let bad_limit =
snapshot_entries_for_workspace(&workspace, SnapshotsQuery { limit: Some(101) })
.expect_err("limit above cap should fail");
assert_eq!(bad_limit.status, StatusCode::BAD_REQUEST);
Ok(())
}
#[tokio::test]
async fn session_delete_returns_404_for_missing_id() -> Result<()> {
let Some((addr, _runtime_threads, handle)) = spawn_test_server().await? else {