app-server: own the Runtime host and control transports; add native stats and per-call cache receipts

Moves the Runtime API, ACP and MCP servers, exec agent, eval, remote control/setup, chat relay and embedded web client from crates/tui/src into crates/app-server/src (git records the 57 moves as renames/deletes plus adds); adds initialized JSONL stdio, websocket, connection, owner_calls, daemon socket/client, turns and thread_control; TUI and CLI call sites switch to codewhale_app_server; bare codewhale app-server becomes stdio; LegacyHttp becomes Http. Also carries the command-contract cache-call facets, /cache calls, and codewhale metrics/stats rewrite. G4 (contract/cache/metrics) was folded into this commit because crates/cli/src/lib.rs, Cargo.toml and Cargo.lock interleave both slices and could not be split cleanly non-interactively. Includes rustfmt-only change in crates/mcp/src/config.rs and all Cargo.lock changes for the batch.

Verification: whole-tree evidence only, not per group. A native21 build of the full dirty tree completed (patch sha ea25c782...), and an unsealed nextest rerun of all 389 selected existing checks passed 389/389 on it. The earlier sealed native20 run failed 369/20 before fixes and was not re-sealed. No CI run.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
CodeWhale BotandClaude Sonnet 5.5 committed 2026-11-03 01:04:35 -08:00
1 parent 32cee2fd1f
commit a31cf5916e
127 files changed
+9814 -7244

No files matched your search

Generated
+49
View File
@@ -873,32 +873,79 @@ name = "codewhale-app-server"
version = "0.10.3"
dependencies = [
"anyhow",
"async-stream",
"async-trait",
"axum",
"base64 0.23.1",
"chrono",
"clap",
"codewhale-agent",
"codewhale-agent-contract",
"codewhale-app-server-protocol",
"codewhale-build-support",
"codewhale-command-contract",
"codewhale-config",
"codewhale-core",
"codewhale-dependencies",
"codewhale-engine",
"codewhale-error-taxonomy",
"codewhale-execpolicy",
"codewhale-hooks",
"codewhale-localization",
"codewhale-lsp",
"codewhale-mcp",
"codewhale-memory",
"codewhale-models",
"codewhale-net",
"codewhale-notify",
"codewhale-palette",
"codewhale-paths",
"codewhale-plugins",
"codewhale-protocol",
"codewhale-release",
"codewhale-runtime",
"codewhale-sandbox",
"codewhale-secrets",
"codewhale-skill-install",
"codewhale-skills",
"codewhale-snapshot",
"codewhale-state",
"codewhale-telemetry",
"codewhale-tools",
"codewhale-tui-kit",
"codewhale-util",
"colored",
"futures-util",
"ignore",
"image",
"libc",
"parking_lot",
"qrcode",
"regex",
"reqwest",
"rmcp",
"rquickjs",
"rusqlite",
"rustls",
"semver",
"serde",
"serde_json",
"sha2 0.11.1",
"tempfile",
"thiserror 2.0.21",
"tokio",
"tokio-tungstenite 0.30.0",
"tokio-util",
"toml 1.1.6+spec-1.1.0",
"tower",
"tower-http 0.7.1",
"tracing",
"url",
"urlencoding",
"uuid",
"webbrowser",
"windows-sys 0.61.2",
"wiremock",
]
[[package]]
@@ -906,6 +953,7 @@ name = "codewhale-app-server-protocol"
version = "0.10.3"
dependencies = [
"chrono",
"jsonschema",
"schemars",
"serde",
"serde_json",
@@ -940,6 +988,7 @@ dependencies = [
"codewhale-state",
"codewhale-telemetry",
"codewhale-tui",
"codewhale-util",
"codewhale-workflow",
"console",
"dirs 7.0.0",
+77 -1
View File
@@ -8,10 +8,23 @@ repository.workspace = true
description = "App-server transport for Codewhale runtime integrations"
# `codewhale app-server` is owned by codewhale-cli; this crate is library-only.
autobins = false
exclude = [
"tests/**",
"src/**/tests.rs",
"src/**/tests/**",
"src/**/*_tests.rs",
"src/**/*test_support.rs",
"src/runtime_web/app.test.mjs",
"src/runtime_web/orca-evidence.html",
]
[lints]
workspace = true
[features]
default = []
test-support = ["codewhale-engine/test-support"]
[dependencies]
codewhale-app-server-protocol = { path = "../app-server-protocol", version = "0.10.3" }
anyhow.workspace = true
@@ -21,13 +34,14 @@ codewhale-agent = { path = "../agent", version = "0.10.3" }
codewhale-config = { path = "../config", version = "0.10.3" }
codewhale-core = { path = "../core", version = "0.10.3" }
codewhale-hooks = { path = "../hooks", version = "0.10.3" }
codewhale-palette = { path = "../palette", version = "0.10.3", default-features = false }
codewhale-paths = { path = "../paths", version = "0.10.3" }
codewhale-protocol = { path = "../protocol", version = "0.10.3" }
codewhale-release = { path = "../release", version = "0.10.3" }
codewhale-runtime = { path = "../runtime", version = "0.10.3" }
codewhale-state = { path = "../state", version = "0.10.3" }
serde.workspace = true
serde_json.workspace = true
serde_json = { workspace = true, features = ["preserve_order", "raw_value"] }
rustls.workspace = true
thiserror.workspace = true
tokio.workspace = true
@@ -36,11 +50,73 @@ tracing.workspace = true
reqwest = { workspace = true, features = ["json"] }
uuid.workspace = true
sha2.workspace = true
async-stream = "0.3.6"
base64 = "0.23.1"
clap.workspace = true
codewhale-command-contract = { path = "../command-contract", version = "0.10.3" }
codewhale-dependencies = { path = "../dependencies", version = "0.10.3" }
codewhale-engine = { path = "../engine", version = "0.10.3" }
codewhale-error-taxonomy = { path = "../error-taxonomy", version = "0.10.3" }
codewhale-execpolicy = { path = "../execpolicy", version = "0.10.3" }
codewhale-localization = { path = "../localization", version = "0.10.3" }
codewhale-lsp = { path = "../lsp", version = "0.10.3" }
codewhale-mcp = { path = "../mcp", version = "0.10.3" }
codewhale-memory = { path = "../memory", version = "0.10.3" }
codewhale-models = { path = "../models", version = "0.10.3" }
codewhale-net = { path = "../net", version = "0.10.3" }
codewhale-notify = { path = "../notify", version = "0.10.3" }
codewhale-plugins = { path = "../plugins", version = "0.10.3" }
codewhale-sandbox = { path = "../sandbox", version = "0.10.3" }
codewhale-secrets = { path = "../secrets", version = "0.10.3" }
codewhale-skill-install = { path = "../skill-install", version = "0.10.3" }
codewhale-skills = { path = "../skills", version = "0.10.3" }
codewhale-snapshot = { path = "../snapshot", version = "0.10.3" }
codewhale-telemetry = { path = "../telemetry", version = "0.10.3" }
codewhale-tools = { path = "../tools", version = "0.10.3" }
codewhale-tui-kit = { path = "../tui-kit", version = "0.10.3" }
codewhale-util = { path = "../util", version = "0.10.3" }
colored = "3.0.0"
futures-util = "0.3.31"
tokio-tungstenite = "0.30"
ignore = "0.4"
parking_lot = "0.12"
qrcode = { version = "0.14", default-features = false }
regex = "1.11"
rmcp = { version = "3.7.0", default-features = false, features = ["auth", "client", "server", "transport-io"] }
semver.workspace = true
tempfile.workspace = true
tokio-util = { version = "0.7.16", features = ["io"] }
toml.workspace = true
url = "2.5"
urlencoding = "2.1"
webbrowser = "1.0"
[dev-dependencies]
rusqlite.workspace = true
tempfile.workspace = true
tower = "0.5"
async-trait.workspace = true
codewhale-agent-contract = { path = "../agent-contract", version = "0.10.3", features = [
"test-support",
] }
codewhale-config = { path = "../config", version = "0.10.3", features = ["schema", "test-support"] }
codewhale-engine = { path = "../engine", version = "0.10.3", features = ["test-support"] }
codewhale-notify = { path = "../notify", version = "0.10.3", features = ["test-thread-scoped-state"] }
codewhale-runtime = { path = "../runtime", version = "0.10.3", features = [
"test-support",
"test-thread-scoped-state",
] }
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif"] }
rquickjs.workspace = true
rustls.workspace = true
wiremock = "0.6"
reqwest = { workspace = true, features = ["stream"] }
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_Storage_FileSystem", "Win32_System_Threading", "Win32_System_Pipes"] }
[build-dependencies]
codewhale-build-support = { path = "../build-support", version = "0.10.3" }
[target.'cfg(unix)'.dependencies]
libc = "0.2"
+10
View File
@@ -0,0 +1,10 @@
use std::path::PathBuf;
fn main() {
let manifest_dir = PathBuf::from(
std::env::var_os("CARGO_MANIFEST_DIR").expect("Cargo sets CARGO_MANIFEST_DIR"),
);
println!("cargo:rerun-if-env-changed=CARGO_MANIFEST_DIR");
codewhale_build_support::declare_rerun_conditions(&manifest_dir);
codewhale_build_support::emit_build_version(&manifest_dir, env!("CARGO_PKG_VERSION"));
}
@@ -84,11 +84,11 @@ pub(crate) fn capture_frontend(
}))
}
impl CapturedAcpFrontend {
pub(crate) fn serve(
&self,
pub(crate) fn serve<'a>(
&'a self,
input: Box<dyn AsyncBufRead + Send + Unpin>,
mut output: Box<dyn AsyncWrite + Send + Unpin>,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + Send + '_>> {
mut output: Box<dyn AsyncWrite + Send + Unpin + 'a>,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + Send + 'a>> {
Box::pin(async move {
let mut server = AcpServer::new(
self.config.clone(),
@@ -99,42 +99,61 @@ impl CapturedAcpFrontend {
self.config_path.clone(),
self.config_profile.clone(),
);
let mut input = codewhale_app_server::stdio::BoundedLines::new(input);
let mut input = crate::stdio::BoundedLines::new(input);
server.serve(&mut input, &mut output).await
})
}
}
#[cfg(test)]
impl codewhale_app_server::RuntimeOwnerFrontend for CapturedAcpFrontend {
impl crate::RuntimeOwnerFrontend for CapturedAcpFrontend {
fn execute(
&self,
_request: crate::RuntimeOwnerRequest,
) -> std::pin::Pin<
Box<dyn std::future::Future<Output = anyhow::Result<serde_json::Value>> + Send + '_>,
> {
Box::pin(async {
anyhow::bail!("this captured frontend admits no ordinary native service")
})
}
fn events(
&self,
_thread_id: String,
_since_seq: u64,
) -> std::pin::Pin<
Box<
dyn std::future::Future<Output = anyhow::Result<crate::RuntimeOwnerEventStream>>
+ Send
+ '_,
>,
> {
Box::pin(async {
anyhow::bail!("this captured frontend admits no ordinary native observation")
})
}
fn validate_selection<'a>(
&'a self,
selection: &'a codewhale_app_server::RuntimeOwnerFrontendSelection,
selection: &'a crate::RuntimeOwnerFrontendSelection,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + Send + 'a>> {
Box::pin(async move {
anyhow::ensure!(
matches!(
selection,
codewhale_app_server::RuntimeOwnerFrontendSelection::Acp { .. }
),
matches!(selection, crate::RuntimeOwnerFrontendSelection::Acp { .. }),
"ACP fixture does not capture HTTP services"
);
Ok(())
})
}
fn serve(
&self,
selection: codewhale_app_server::RuntimeOwnerFrontendSelection,
_compatibility: codewhale_app_server::AppState,
fn serve<'a>(
&'a self,
selection: crate::RuntimeOwnerFrontendSelection,
_compatibility: crate::AppState,
input: Box<dyn AsyncBufRead + Send + Unpin>,
output: Box<dyn AsyncWrite + Send + Unpin>,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + Send + '_>> {
output: Box<dyn AsyncWrite + Send + Unpin + 'a>,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + Send + 'a>> {
Box::pin(async move {
anyhow::ensure!(
matches!(
selection,
codewhale_app_server::RuntimeOwnerFrontendSelection::Acp { .. }
),
matches!(selection, crate::RuntimeOwnerFrontendSelection::Acp { .. }),
"ACP fixture does not capture HTTP services"
);
CapturedAcpFrontend::serve(self, input, output).await
@@ -200,14 +219,14 @@ impl AcpServer {
}
async fn serve<R: AsyncBufRead + Unpin, W: AsyncWrite + Unpin>(
&mut self,
reader: &mut codewhale_app_server::stdio::BoundedLines<R>,
reader: &mut crate::stdio::BoundedLines<R>,
writer: &mut W,
) -> Result<()> {
loop {
let line = match reader.next_line().await {
Ok(Some(line)) => line,
Ok(None) => break,
Err(error) if codewhale_app_server::stdio::is_frame_refusal(&error) => {
Err(error) if crate::stdio::is_frame_refusal(&error) => {
write_jsonrpc_error(writer, None, -32700, error.to_string()).await?;
continue;
}
@@ -224,7 +243,7 @@ impl AcpServer {
continue;
}
};
if codewhale_app_server::is_control_input_closed(&message) {
if crate::is_control_input_closed(&message) {
break;
}
let response_id = message
@@ -257,7 +276,7 @@ impl AcpServer {
}
}
Err(error) => {
if codewhale_app_server::stdio::is_control_write_failure(&error) {
if crate::stdio::is_control_write_failure(&error) {
return Err(error);
}
write_jsonrpc_error(
@@ -286,7 +305,7 @@ impl AcpServer {
(Ok(()), Some(id)) => write_jsonrpc_result(writer, id, result).await?,
(Ok(()), None) => {}
(Err(error), id) => {
if codewhale_app_server::stdio::is_control_write_failure(&error) {
if crate::stdio::is_control_write_failure(&error) {
return Err(error);
}
write_jsonrpc_error(writer, id, -32603, error.to_string()).await?
@@ -1264,26 +1283,23 @@ async fn write_json_line<W>(writer: &mut W, mut value: Value) -> Result<()>
where
W: AsyncWrite + Unpin,
{
let frame = match codewhale_app_server::stdio::serialize_control_frame(&value) {
let frame = match crate::stdio::serialize_control_frame(&value) {
Ok(frame) => frame,
Err(error)
if codewhale_app_server::stdio::is_frame_refusal(&error)
&& value.get("method").is_none() =>
{
Err(error) if crate::stdio::is_frame_refusal(&error) && value.get("method").is_none() => {
let id = value.get_mut("id").map(Value::take).unwrap_or(Value::Null);
let mut failure = json!({"jsonrpc":"2.0","id":id,"error":{"code":-32603,"message":"ACP response exceeds the 8 MiB transport limit; inspect canonical state without replay"}});
match codewhale_app_server::stdio::serialize_control_frame(&failure) {
match crate::stdio::serialize_control_frame(&failure) {
Ok(frame) => frame,
Err(error) if codewhale_app_server::stdio::is_frame_refusal(&error) => {
Err(error) if crate::stdio::is_frame_refusal(&error) => {
failure["id"] = Value::Null;
codewhale_app_server::stdio::serialize_control_frame(&failure)?
crate::stdio::serialize_control_frame(&failure)?
}
Err(error) => return Err(error.into()),
}
}
Err(error) => return Err(error.into()),
};
codewhale_app_server::stdio::write_control_line(writer, &frame).await
crate::stdio::write_control_line(writer, &frame).await
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -1369,7 +1385,7 @@ mod tests {
pub(super) server: AcpServer,
mock: Arc<MockLlmClient>,
pub(super) workspace: PathBuf,
_home: crate::test_support::SealedHome,
_home: codewhale_engine::test_support::SealedHome,
_dir: tempfile::TempDir,
}
impl Rig {
@@ -1385,7 +1401,7 @@ mod tests {
acp: bool,
) -> Result<Self> {
let dir = tempfile::tempdir()?;
let home = crate::test_support::SealedHome::at(dir.path());
let home = codewhale_engine::test_support::SealedHome::at(dir.path());
let workspace = dir.path().join("workspace");
std::fs::create_dir(&workspace)?;
let runtime_dir = dir.path().join("runtime");
@@ -1447,7 +1463,7 @@ mod tests {
) -> Result<(&'static str, Vec<Value>)> {
// Keep the transport input open: an empty reader is a real EOF/cancel.
let (client, input) = tokio::io::duplex(4096);
let mut reader = codewhale_app_server::stdio::BoundedLines::new(BufReader::new(input));
let mut reader = crate::stdio::BoundedLines::new(BufReader::new(input));
let mut output = Vec::new();
let result = tokio::time::timeout(
Duration::from_secs(if cfg!(windows) { 120 } else { 20 }),
@@ -1498,7 +1514,7 @@ mod tests {
}
}
async fn correlated(
guest: &mut codewhale_app_server::daemon_client::OwnerClient,
guest: &mut crate::daemon_client::OwnerClient,
id: Value,
) -> Result<Value> {
tokio::time::timeout(Duration::from_secs(30), async {
@@ -1551,10 +1567,7 @@ mod tests {
execution_scope: binding.execution_scope,
lease_generation: generation,
pid: std::process::id(),
process_start: codewhale_app_server::daemon_socket::capture_process_start(
std::process::id(),
)
.await?,
process_start: crate::daemon_socket::capture_process_start(std::process::id()).await?,
principal,
socket_path: socket_path.clone(),
config_path: None,
@@ -1570,11 +1583,11 @@ mod tests {
)?;
// ACP calls the held manager directly through its captured projection;
// this closed endpoint has no fake HTTP service and is never used.
let (daemon, _state) = codewhale_app_server::bind_runtime_frontends(
let (daemon, _state) = crate::bind_runtime_frontends(
None,
None,
receipt.clone(),
codewhale_app_server::RuntimeOwnerRouting {
crate::RuntimeOwnerRouting {
workers: None,
workspace: Some(rig.workspace.clone()),
endpoint: "127.0.0.1:1".parse()?,
@@ -1595,12 +1608,10 @@ mod tests {
let shutdown = daemon.shutdown_handle();
let owner = tokio::spawn(daemon.serve());
let _abort = AbortOnDrop(owner.abort_handle());
let mut guest = codewhale_app_server::daemon_client::connect_acp_if_published(
None,
Some(socket_path.clone()),
)
.await?
.ok_or_else(|| anyhow!("published ACP owner unavailable"))?;
let mut guest =
crate::daemon_client::connect_acp_if_published(None, Some(socket_path.clone()))
.await?
.ok_or_else(|| anyhow!("published ACP owner unavailable"))?;
assert_eq!(guest.receipt(), &receipt);
assert!(guest.routing().is_some_and(|routing| routing.acp));
guest
@@ -1633,12 +1644,10 @@ mod tests {
guest.forward(tokio::io::empty(), tokio::io::sink()),
)
.await??;
let mut shutdown_guest = codewhale_app_server::daemon_client::connect_acp_if_published(
None,
Some(socket_path.clone()),
)
.await?
.ok_or_else(|| anyhow!("owner ended after guest EOF"))?;
let mut shutdown_guest =
crate::daemon_client::connect_acp_if_published(None, Some(socket_path.clone()))
.await?
.ok_or_else(|| anyhow!("owner ended after guest EOF"))?;
shutdown_guest.send(json!(4), "shutdown", json!({})).await?;
let closed = correlated(&mut shutdown_guest, json!(4)).await?;
assert!(closed["error"].is_null());
@@ -1721,7 +1730,7 @@ mod tests {
shutdown.trigger();
tokio::time::timeout(Duration::from_secs(5), owner).await???;
assert!(
codewhale_app_server::daemon_client::connect_acp_if_published(None, Some(socket_path))
crate::daemon_client::connect_acp_if_published(None, Some(socket_path))
.await?
.is_none(),
"shutdown withdraws the authenticated owner receipt instead of advertising a guest"
@@ -2456,9 +2465,9 @@ mod tests {
let target = rig.workspace.join("ask.txt");
let (client, transport) = tokio::io::duplex(65536);
let (read, mut write) = tokio::io::split(transport);
let mut reader = codewhale_app_server::stdio::BoundedLines::new(BufReader::new(read));
let mut reader = crate::stdio::BoundedLines::new(BufReader::new(read));
let (cr, mut cw) = tokio::io::split(client);
let mut cr = codewhale_app_server::stdio::BoundedLines::new(BufReader::new(cr));
let mut cr = crate::stdio::BoundedLines::new(BufReader::new(cr));
let sid = id.clone();
let target_in = target.clone();
let client = async move {
@@ -2844,13 +2853,13 @@ mod tests {
);
let sentinel = rig.workspace.join("floor-sentinel.txt");
for _ in 0..180 {
if crate::test_support::read_shell_sentinel(&sentinel) == "rm -rf /" {
if codewhale_engine::test_support::read_shell_sentinel(&sentinel) == "rm -rf /" {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
}
assert_eq!(
crate::test_support::read_shell_sentinel(&sentinel),
codewhale_engine::test_support::read_shell_sentinel(&sentinel),
"rm -rf /"
);
assert!(!rig.workspace.join("CHANGELOG.md").exists());
@@ -3139,8 +3148,7 @@ mod tests {
json!({"jsonrpc":"2.0","id":5,"method":"fabricated"}),
json!({"jsonrpc":"2.0","id":6,"method":"shutdown"})
);
let mut reader =
codewhale_app_server::stdio::BoundedLines::new(BufReader::new(input.as_bytes()));
let mut reader = crate::stdio::BoundedLines::new(BufReader::new(input.as_bytes()));
let mut output = Vec::new();
rig.server.serve(&mut reader, &mut output).await?;
let wire = parse_lines(output);
@@ -46,7 +46,7 @@ impl AcpServer {
pub(super) async fn drive_prompt<R: AsyncBufRead + Unpin, W: AsyncWrite + Unpin>(
&mut self,
params: Value,
reader: &mut codewhale_app_server::stdio::BoundedLines<R>,
reader: &mut crate::stdio::BoundedLines<R>,
writer: &mut W,
) -> Result<&'static str> {
let (session_id, prompt) = self
@@ -213,7 +213,7 @@ impl AcpServer {
&self,
projection: &mut Projection<'_>,
receiver: &mut tokio::sync::broadcast::Receiver<RuntimeEventRecord>,
reader: &mut codewhale_app_server::stdio::BoundedLines<R>,
reader: &mut crate::stdio::BoundedLines<R>,
writer: &mut W,
) -> Result<TurnRecord> {
let session = projection.session;
@@ -235,7 +235,7 @@ impl AcpServer {
line = reader.next_line(), if !eof => {
let line = match line {
Ok(line) => line,
Err(error) if codewhale_app_server::stdio::is_frame_refusal(&error) => {
Err(error) if crate::stdio::is_frame_refusal(&error) => {
write_jsonrpc_error(writer, None, -32700, error.to_string()).await?;
continue;
}
@@ -250,7 +250,7 @@ impl AcpServer {
Ok(message) => message,
Err(error) => { write_jsonrpc_error(writer, None, -32700, format!("invalid json: {error}")).await?; continue; }
};
if codewhale_app_server::is_control_input_closed(&message) {
if crate::is_control_input_closed(&message) {
eof=true; cancelled=true; projection.permissions.clear();self.interrupt_claimed_turn(thread,turn).await?;
cancel_deadline.get_or_insert(tokio::time::Instant::now()+Duration::from_secs(30));continue;
}
@@ -313,14 +313,16 @@ impl AcpServer {
.pointer("/result/outcome/optionId")
.and_then(Value::as_str)
== Some("allow-once");
self.runtime.deliver_external_approval(
&permission.approval_id,
if allow {
ExternalApprovalDecision::Allow { remember: false }
} else {
ExternalApprovalDecision::Deny { remember: false }
},
);
self.runtime
.deliver_external_approval(
&permission.approval_id,
if allow {
ExternalApprovalDecision::Allow { remember: false }
} else {
ExternalApprovalDecision::Deny { remember: false }
},
)
.await?;
Ok(())
}
async fn project_event<W: AsyncWrite + Unpin>(
@@ -537,6 +539,7 @@ mod tests {
engine
.tx_event
.send(Event::ApprovalRequired {
publication: None,
id: "acp-policy-call".into(),
tool_name: "write".into(),
description: "Requested by extension:claimed".into(),
@@ -681,10 +684,16 @@ mod tests {
.ok_or_else(|| anyhow!("opaque approval ID missing"))?;
assert_ne!(approval, "acp-policy-call");
for id in [approval, "acp-policy-call"] {
assert!(!rig.server.runtime.deliver_external_approval(
id,
ExternalApprovalDecision::Allow { remember: false }
));
assert!(
!rig.server
.runtime
.deliver_external_approval(
id,
ExternalApprovalDecision::Allow { remember: false }
)
.await
.expect("durable approval decision")
);
}
if !forced || extension {
engine
@@ -959,7 +968,7 @@ mod tests {
)?;
let id = rig.new_session().await;
let (client, input) = tokio::io::duplex(1024);
let mut reader = codewhale_app_server::stdio::BoundedLines::new(BufReader::new(input));
let mut reader = crate::stdio::BoundedLines::new(BufReader::new(input));
let mut writer = FailedAnswerWriter(Vec::new());
let error = tokio::time::timeout(
Duration::from_secs(20),
+41 -302
View File
@@ -1,21 +1,29 @@
#[cfg(test)]
use crate::stdio::{emit_stdio_event, interrupt_turn_request};
#[cfg(test)]
use crate::turns::{RuntimeTurnInput, extract_runtime_thread_id, turn_terminal_status};
use crate::{AppState, DispatchError, RuntimeBridge, SharedRuntimeBridge, thread_control};
#[cfg(test)]
use crate::{
AppState, InFlightTurn, JsonRpcError, MAX_SSE_FRAME_BYTES, RuntimeBridge, RuntimeThreadHint,
SharedRuntimeBridge, TurnRegistry, TurnTerminalStatus, TurnTranscript, thread_control,
InFlightTurn, MAX_SSE_FRAME_BYTES, RuntimeThreadHint, TurnExecutionOwner, TurnRegistry,
TurnTerminalStatus, TurnTranscript,
};
use anyhow::Context as _;
use anyhow::{Result, anyhow, bail};
#[cfg(test)]
use codewhale_protocol::{EventFrame, ResponseChannel};
use serde_json::{Value, json};
#[cfg(test)]
use std::collections::HashMap;
#[cfg(test)]
use std::path::Path;
#[cfg(test)]
use std::path::PathBuf;
#[cfg(test)]
use std::process::{Command, Stdio};
use std::sync::Arc;
use std::time::Duration;
#[cfg(test)]
use tokio::io::AsyncWrite;
#[cfg(all(test, unix))]
use tokio::sync::Mutex;
@@ -25,7 +33,7 @@ use tokio::sync::Mutex;
pub(crate) async fn acquire_historical_runtime_bridge<F, Fut>(
state: &AppState,
start: F,
) -> std::result::Result<SharedRuntimeBridge, JsonRpcError>
) -> std::result::Result<SharedRuntimeBridge, DispatchError>
where
F: FnOnce() -> Fut,
Fut: std::future::Future<Output = Result<RuntimeBridge>>,
@@ -34,13 +42,13 @@ where
return Ok(bridge.clone());
}
if state.captured_owner.is_some() {
return Err(JsonRpcError::runtime_unavailable(
return Err(DispatchError::runtime_unavailable(
"the captured Runtime owner is unavailable; refusing another owner",
));
}
let bridge =
Arc::new(Mutex::new(start().await.map_err(|err| {
JsonRpcError::runtime_unavailable(err.to_string())
DispatchError::runtime_unavailable(err.to_string())
})?));
let mut slot = state.runtime_bridge.lock().await;
// Prefer a bridge cached by a concurrent caller while we were spawning;
@@ -52,7 +60,7 @@ where
/// store or transcript can be created when this captured connection is absent.
pub(crate) async fn acquire_runtime_bridge(
state: &AppState,
) -> std::result::Result<SharedRuntimeBridge, JsonRpcError> {
) -> std::result::Result<SharedRuntimeBridge, DispatchError> {
state
.runtime_bridge
.lock()
@@ -60,225 +68,16 @@ pub(crate) async fn acquire_runtime_bridge(
.as_ref()
.cloned()
.ok_or_else(|| {
JsonRpcError::runtime_unavailable(
DispatchError::runtime_unavailable(
"the captured Runtime owner is unavailable; refusing another owner",
)
})
}
pub(crate) async fn runtime_catalog_models(
request: reqwest::RequestBuilder,
provider: &str,
provider_id: Option<&str>,
) -> Result<Vec<Value>> {
let (client, request) = request.build_split();
let request = request?;
let mut models = Vec::new();
let mut cursor = None;
let mut cursors = std::collections::HashSet::new();
let mut ids = std::collections::HashSet::new();
let mut total = None;
for _ in 0..40 {
let mut page = request
.try_clone()
.context("Runtime catalog request cannot be replayed")?;
if let Some(cursor) = cursor.as_deref() {
page.url_mut()
.query_pairs_mut()
.append_pair("cursor", cursor);
}
let catalog = thread_control::read_json_response(client.execute(page).await?).await?;
anyhow::ensure!(
catalog.get("provider").and_then(Value::as_str) == Some(provider)
&& catalog.get("model_provider_id").unwrap_or(&Value::Null) == &json!(provider_id),
"Runtime model catalog changed provider identity"
);
let count = catalog
.get("total")
.and_then(Value::as_u64)
.context("Runtime model catalog omitted its total")?;
anyhow::ensure!(
count <= 10_000 && total.is_none_or(|total| total == count),
"Runtime model catalog size changed or exceeds its limit"
);
total = Some(count);
let entries = catalog
.get("models")
.and_then(Value::as_array)
.context("Runtime model catalog omitted its models")?;
anyhow::ensure!(
entries.len() <= 250,
"Runtime model catalog page exceeds its limit"
);
for entry in entries {
let id = entry
.get("id")
.and_then(Value::as_str)
.filter(|id| !id.is_empty())
.context("Runtime model catalog contains an invalid model")?;
anyhow::ensure!(
ids.insert(id.to_string()),
"Runtime model catalog repeated a model"
);
models.push(entry.clone());
}
anyhow::ensure!(
models.len() as u64 <= count,
"Runtime model catalog exceeds its total"
);
match catalog.get("nextCursor") {
None | Some(Value::Null) => {
anyhow::ensure!(
models.len() as u64 == count,
"Runtime model catalog is incomplete"
);
return Ok(models);
}
Some(Value::String(next)) if !next.is_empty() && next.len() <= 1_368 => {
anyhow::ensure!(
!entries.is_empty() && models.len() < count as usize,
"Runtime model catalog continuation is invalid"
);
anyhow::ensure!(
cursors.insert(next.clone()),
"Runtime model catalog cursor repeated"
);
cursor = Some(next.clone());
}
_ => bail!("Runtime model catalog cursor is invalid"),
}
}
bail!("Runtime model catalog exceeded its page limit")
}
pub(crate) async fn runtime_app_models(state: &AppState) -> Result<Vec<Value>> {
let shared = acquire_runtime_bridge(state)
.await
.map_err(|error| anyhow!(error.message))?;
let transport = state
.runtime_catalog_transport
.as_ref()
.context("the captured Runtime catalog transport is unavailable")?;
let captured = transport
.bridge
.upgrade()
.context("the captured Runtime catalog owner is unavailable")?;
anyhow::ensure!(
Arc::ptr_eq(&shared, &captured),
"the captured Runtime catalog owner changed; refusing another owner"
);
let base_url = &transport.base_url;
let client = &transport.client;
let authed = |request: reqwest::RequestBuilder| match transport.auth_token.as_deref() {
Some(token) => request.bearer_auth(token),
None => request,
};
let providers = thread_control::read_json_response(
authed(client.get(format!("{base_url}/v1/providers")))
.timeout(Duration::from_secs(10))
.send()
.await?,
)
.await?;
let providers = providers
.get("providers")
.and_then(Value::as_array)
.context("Runtime provider catalog is invalid")?;
anyhow::ensure!(
providers.len() <= 256,
"Runtime provider catalog exceeds its limit"
);
let aliases = state.registry.list();
let mut models = Vec::new();
let mut routes = std::collections::HashSet::new();
for provider in providers {
if !provider
.get("has_model_catalog")
.and_then(Value::as_bool)
.context("Runtime provider omitted its catalog availability")?
{
continue;
}
let kind = provider
.get("id")
.and_then(Value::as_str)
.context("Runtime provider omitted its identity")?;
let parsed: codewhale_config::ProviderKind =
serde_json::from_value(json!(kind)).context("Runtime provider kind is unsupported")?;
anyhow::ensure!(
kind == parsed.as_str()
|| codewhale_config::descriptors::compatibility_for_id(kind)
.is_some_and(|compatibility| compatibility.kind == parsed),
"Runtime provider kind is not a recognized wire identity"
);
let provider_id = match provider.get("model_provider_id") {
None | Some(Value::Null) => None,
Some(Value::String(id)) if !id.is_empty() => Some(id.as_str()),
_ => bail!("Runtime configured provider identity is invalid"),
};
if !routes.insert((kind.to_string(), provider_id.map(str::to_string))) {
continue;
}
let mut url = reqwest::Url::parse(&format!("{base_url}/v1/providers/{kind}/models"))?;
url.query_pairs_mut().append_pair("limit", "250");
if let Some(id) = provider_id {
url.query_pairs_mut().append_pair("model_provider_id", id);
}
let request = authed(client.get(url)).timeout(Duration::from_secs(10));
for mut model in runtime_catalog_models(request, kind, provider_id).await? {
let id = model
.get("id")
.and_then(Value::as_str)
.context("Runtime model identity is invalid")?;
let compatibility_aliases = aliases
.iter()
.find(|entry| {
parsed != codewhale_config::ProviderKind::Custom
&& provider_id.is_none_or(|identity| identity == kind)
&& entry.provider == parsed
&& entry.id == id
})
.map(|entry| entry.aliases.clone())
.unwrap_or_default();
let capabilities: codewhale_config::route::RouteCapabilities =
match model.pointer("/facts/capabilities") {
None | Some(Value::Null) => Default::default(),
Some(value) => serde_json::from_value(value.clone())
.context("Runtime model capabilities are invalid")?,
};
let row = model
.as_object_mut()
.context("Runtime model entry is invalid")?;
row.insert("provider".into(), serde_json::to_value(parsed)?);
row.insert("model_provider_id".into(), json!(provider_id));
row.insert("aliases".into(), json!(compatibility_aliases));
row.insert(
"supports_tools".into(),
json!(
capabilities.native_tool_calls
== codewhale_config::route::CapabilityState::Supported
),
);
row.insert(
"supports_reasoning".into(),
json!(
capabilities.reasoning == codewhale_config::route::CapabilityState::Supported
),
);
row.insert("capabilities".into(), serde_json::to_value(capabilities)?);
models.push(model);
anyhow::ensure!(
models.len() <= 100_000,
"Runtime model catalog exceeds the response limit"
);
}
}
Ok(models)
}
#[cfg(test)]
pub(crate) async fn acquire_live_runtime_bridge(
state: &AppState,
) -> std::result::Result<tokio::sync::OwnedMutexGuard<RuntimeBridge>, JsonRpcError> {
) -> std::result::Result<tokio::sync::OwnedMutexGuard<RuntimeBridge>, DispatchError> {
Ok(acquire_runtime_bridge(state).await?.lock_owned().await)
}
@@ -287,7 +86,7 @@ pub(crate) async fn acquire_live_runtime_bridge(
pub(crate) async fn acquire_live_runtime_bridge_with<F, Fut>(
state: &AppState,
start: F,
) -> std::result::Result<tokio::sync::OwnedMutexGuard<RuntimeBridge>, JsonRpcError>
) -> std::result::Result<tokio::sync::OwnedMutexGuard<RuntimeBridge>, DispatchError>
where
F: Fn() -> Fut,
Fut: std::future::Future<Output = Result<RuntimeBridge>>,
@@ -309,7 +108,7 @@ where
*slot = None;
}
}
Err(JsonRpcError::runtime_unavailable(
Err(DispatchError::runtime_unavailable(
"runtime API bridge exited immediately after starting",
))
}
@@ -368,71 +167,11 @@ impl RuntimeBridge {
thread_control::read_json_response(builder.send().await?).await
}
/// Read the existing Runtime catalog before a one-shot prompt can create
/// its thread. Existing threads are checked by canonical turn admission.
pub(crate) async fn require_output_limited_model(
&self,
requested_model: Option<&str>,
) -> Result<()> {
let providers = self
.request_json(self.authed(self.client.get(format!("{}/v1/providers", self.base_url))))
.await?;
let current = providers
.get("current")
.and_then(Value::as_str)
.context("Runtime provider is unavailable")?;
let current_id = match providers.get("current_provider_id") {
None | Some(Value::Null) => None,
Some(Value::String(id)) if !id.is_empty() => Some(id.as_str()),
_ => bail!("Runtime configured provider identity is invalid"),
};
let provider = providers
.get("providers")
.and_then(Value::as_array)
.and_then(|providers| {
providers.iter().find(|provider| {
provider.get("id").and_then(Value::as_str) == Some(current)
&& provider.get("model_provider_id").and_then(Value::as_str) == current_id
})
})
.context("Runtime provider is unavailable")?;
let model = requested_model
.or_else(|| provider.get("default_model").and_then(Value::as_str))
.context("maxOutputTokens requires an exact model")?;
if model.trim().is_empty() || model.eq_ignore_ascii_case("auto") {
bail!("maxOutputTokens requires an exact model");
}
if !current
.bytes()
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
{
bail!("Runtime provider identity is invalid");
}
let mut url =
reqwest::Url::parse(&format!("{}/v1/providers/{current}/models", self.base_url))?;
url.query_pairs_mut().append_pair("limit", "250");
if let Some(id) = current_id {
url.query_pairs_mut().append_pair("model_provider_id", id);
}
let request = self
.authed(self.client.get(url))
.timeout(Duration::from_secs(10));
let catalog = runtime_catalog_models(request, current, current_id).await?;
let entry = catalog
.iter()
.find(|entry| entry.get("id").and_then(Value::as_str) == Some(model))
.context("Output-limit support is unknown for the selected Runtime model")?;
anyhow::ensure!(
entry.get("output_token_limit").and_then(Value::as_str) == Some("supported"),
"The selected Runtime model does not support maxOutputTokens"
);
Ok(())
}
/// Resolve `stdio_thread_id` to a runtime thread, minting one only when
/// `thread_map` has no entry. The map lives on [`AppState`] and outlives
/// this bridge, so a thread created under a previous child keeps its id
/// here as long as the store it was persisted to is shared (#6246).
#[cfg(test)]
pub(crate) async fn ensure_runtime_thread(
&mut self,
thread_map: &mut HashMap<String, String>,
@@ -450,18 +189,7 @@ impl RuntimeBridge {
Ok(runtime_thread_id)
}
/// Drop a thread mapping (and its seq cursor) once no caller can name
/// the client-facing key again.
pub(crate) fn forget_thread(
&mut self,
thread_map: &mut HashMap<String, String>,
stdio_thread_id: &str,
) {
if let Some(runtime_thread_id) = thread_map.remove(stdio_thread_id) {
self.last_seq_by_thread.remove(&runtime_thread_id);
}
}
#[cfg(test)]
async fn create_runtime_thread(
&mut self,
model: Option<String>,
@@ -490,6 +218,7 @@ impl RuntimeBridge {
/// `registration` is `Some` on the stdio path: it publishes the live turn
/// so an `thread/interrupt` arriving mid-stream can reach the runtime
/// without waiting on the bridge mutex this call holds.
#[cfg(test)]
pub(crate) async fn message_thread<W: AsyncWrite + Unpin>(
&mut self,
thread_id: &str,
@@ -503,6 +232,7 @@ impl RuntimeBridge {
images,
max_output_tokens,
expected_workspace,
cancellation: _,
} = input;
let mut request = json!({ "prompt": input, "allowed_tools": [] });
if let Some(workspace) = expected_workspace {
@@ -567,8 +297,10 @@ impl RuntimeBridge {
.await
{
let orphan = InFlightTurn {
base_url: self.base_url.clone(),
auth_token: self.auth_token.clone(),
owner: TurnExecutionOwner::HistoricalHttp {
base_url: self.base_url.clone(),
auth_token: self.auth_token.clone(),
},
runtime_thread_id: thread_id.to_string(),
turn_id: turn_id.clone(),
};
@@ -577,7 +309,7 @@ impl RuntimeBridge {
thread_id,
turn_id = %turn_id,
"failed to interrupt a turn whose response_start could not be written: {}",
interrupt.message
interrupt.wire.message
);
}
return Err(error);
@@ -590,8 +322,10 @@ impl RuntimeBridge {
registry.lock().await.insert(
key.clone(),
InFlightTurn {
base_url: self.base_url.clone(),
auth_token: self.auth_token.clone(),
owner: TurnExecutionOwner::HistoricalHttp {
base_url: self.base_url.clone(),
auth_token: self.auth_token.clone(),
},
runtime_thread_id: thread_id.to_string(),
turn_id: turn_id.clone(),
},
@@ -637,8 +371,10 @@ impl RuntimeBridge {
// message until the orphan finished. Stop it, best effort, and
// let the error below be the only outcome the client sees.
let orphan = InFlightTurn {
base_url: self.base_url.clone(),
auth_token: self.auth_token.clone(),
owner: TurnExecutionOwner::HistoricalHttp {
base_url: self.base_url.clone(),
auth_token: self.auth_token.clone(),
},
runtime_thread_id: thread_id.to_string(),
turn_id: turn_id.clone(),
};
@@ -647,7 +383,7 @@ impl RuntimeBridge {
thread_id,
turn_id = %turn_id,
"failed to interrupt a turn whose event stream broke: {}",
error.message
error.wire.message
);
}
}
@@ -685,6 +421,7 @@ impl RuntimeBridge {
}
}
#[cfg(test)]
async fn stream_turn_events<W: AsyncWrite + Unpin>(
&self,
thread_id: &str,
@@ -761,7 +498,7 @@ impl RuntimeBridge {
}
}
"turn.completed" => {
let status = turn_terminal_status(&payload);
let status = turn_terminal_status(&payload)?;
let error = payload
.pointer("/turn/error")
.and_then(Value::as_str)
@@ -876,6 +613,7 @@ pub(crate) fn install_rustls_crypto_provider() {
let _ = rustls::crypto::ring::default_provider().install_default();
}
#[cfg(test)]
pub(crate) fn take_sse_frame(buffer: &mut Vec<u8>) -> Option<Vec<u8>> {
if let Some(pos) = buffer.windows(4).position(|window| window == b"\r\n\r\n") {
return Some(buffer.drain(..pos + 4).collect());
@@ -886,6 +624,7 @@ pub(crate) fn take_sse_frame(buffer: &mut Vec<u8>) -> Option<Vec<u8>> {
.map(|pos| buffer.drain(..pos + 2).collect())
}
#[cfg(test)]
pub(crate) fn parse_sse_frame(frame_bytes: &[u8]) -> Option<(String, String)> {
let text = String::from_utf8(frame_bytes.to_vec()).ok()?;
let mut event_name = None;
File diff suppressed because it is too large. Load diff
+13 -34
View File
@@ -1,15 +1,14 @@
#[cfg(test)]
use crate::bridge::invalidate_runtime_bridge;
use crate::bridge::{acquire_runtime_bridge, runtime_app_models};
use crate::http::ADVERTISED_ROUTES;
use crate::turns::handle_thread_request;
use crate::owner_calls::runtime_app_models;
use crate::{AppState, AppTransport};
use anyhow::Context as _;
#[cfg(not(test))]
use anyhow::bail;
use anyhow::{Result, anyhow};
use codewhale_config::ConfigStore;
use codewhale_protocol::{AppRequest, AppResponse, ThreadListParams, ThreadRequest};
use codewhale_protocol::{AppRequest, AppResponse};
use serde_json::json;
use std::time::Duration;
@@ -138,14 +137,7 @@ pub(crate) async fn process_app_request(
}
}
AppRequest::ThreadLoadedList => {
let response = handle_thread_request(
state,
ThreadRequest::List(ThreadListParams {
include_archived: false,
limit: Some(50),
}),
)
.await;
let response = crate::thread_control::loaded(state).await;
match response {
Ok(thread_resp) => AppResponse {
ok: true,
@@ -154,7 +146,7 @@ pub(crate) async fn process_app_request(
},
Err(err) => AppResponse {
ok: false,
data: json!({ "error": err.message }),
data: json!({ "error": err.to_string() }),
events: Vec::new(),
},
}
@@ -201,28 +193,15 @@ pub(crate) async fn propagate_config(state: &AppState) -> Result<()> {
runtime.update_config(snapshot);
}
if state.captured_owner.is_some() {
let shared = acquire_runtime_bridge(state)
.await
.map_err(|_| anyhow!("config saved but captured Runtime owner is unavailable"))?;
let request = {
let bridge = shared.lock().await;
bridge
.authed(
bridge
.client
.post(format!("{}/v1/config/reload", bridge.base_url)),
)
.timeout(Duration::from_secs(10))
};
let response = request
.send()
.await
.context("config saved; captured owner reload outcome is uncertain, not replayed")?;
anyhow::ensure!(
response.status().is_success(),
"config saved but captured owner rejected reload (status {})",
response.status()
);
let owner = crate::owner_calls::captured(state)
.context("config saved but captured Runtime owner is unavailable")?;
tokio::time::timeout(
Duration::from_secs(10),
crate::owner_calls::execute(&owner, crate::RuntimeOwnerRequest::ReloadConfig),
)
.await
.context("config saved; captured owner reload outcome is uncertain, not replayed")?
.context("config saved but captured owner rejected reload")?;
} else {
#[cfg(test)]
invalidate_runtime_bridge(state).await;
+59
View File
@@ -0,0 +1,59 @@
use crate::{DispatchError, params_or_object, parse_params};
use codewhale_app_server_protocol::lifecycle::{InitializeParams, InitializeResponse};
use serde_json::Value;
#[derive(Default)]
pub(crate) struct ConnectionSession {
initialization: Option<InitializeParams>,
}
impl ConnectionSession {
pub(crate) fn initialized(&self) -> bool {
self.initialization.is_some()
}
pub(crate) fn initialize(&mut self, params: Value) -> Result<Value, DispatchError> {
if self.initialized() {
return Err(DispatchError::invalid_request("Already initialized"));
}
let params: InitializeParams = parse_params(params_or_object(params))?;
axum::http::HeaderValue::from_str(&params.client_info.name)
.map_err(|_| DispatchError::invalid_request("Invalid clientInfo.name"))?;
if let Some(capabilities) = params.capabilities.as_ref()
&& (capabilities.explicit_gateway_oauth
|| capabilities.request_attestation
|| capabilities.mcp_server_openai_form_elicitation
|| capabilities
.opt_out_notification_methods
.as_ref()
.is_some_and(|methods| !methods.is_empty())
|| capabilities
.extensions
.as_ref()
.is_some_and(|extensions| !extensions.is_empty()))
{
return Err(DispatchError::invalid_request(
"Requested initialization capability is not implemented",
));
}
let home = codewhale_paths::codewhale_home()
.map_err(|error| DispatchError::internal(error.to_string()))?
.filter(|path| path.is_absolute())
.ok_or_else(|| DispatchError::internal("absolute Codewhale home unavailable"))?;
let response = InitializeResponse {
user_agent: format!(
"Codewhale/{} ({}; {})",
env!("CARGO_PKG_VERSION"),
params.client_info.name,
params.client_info.version
),
codex_home: home,
platform_family: std::env::consts::FAMILY.to_string(),
platform_os: std::env::consts::OS.to_string(),
};
let value = serde_json::to_value(response)
.map_err(|error| DispatchError::internal(error.to_string()))?;
self.initialization = Some(params);
Ok(value)
}
}
+115 -14
View File
@@ -17,6 +17,10 @@ mod platform {
write_control_line, write_stdio_line,
};
use anyhow::{Context as _, bail};
use codewhale_app_server_protocol::lifecycle::InitializeResponse;
use codewhale_app_server_protocol::rpc::{
RequestId, RpcCall, RpcErrorResponse, RpcNotification, RpcResponse,
};
use codewhale_config::private_directory::PrivateDirectory;
#[cfg(unix)]
use codewhale_paths::daemon_socket::default_socket_path;
@@ -24,7 +28,7 @@ mod platform {
use serde_json::{Value, json};
use std::sync::Arc;
use std::time::Duration;
use tokio::io::{AsyncWriteExt as _, BufReader};
use tokio::io::BufReader;
#[cfg(unix)]
use tokio::net::UnixStream;
#[cfg(unix)]
@@ -32,14 +36,21 @@ mod platform {
#[cfg(windows)]
type Connection = tokio::net::windows::named_pipe::NamedPipeClient;
type OwnerTransport = crate::websocket::LocalWebSocket<
tokio::io::ReadHalf<Connection>,
tokio::io::WriteHalf<Connection>,
>;
pub struct OwnerClient {
read: BoundedLines<BufReader<tokio::io::ReadHalf<Connection>>>,
write: tokio::io::WriteHalf<Connection>,
read: BoundedLines<BufReader<tokio::io::ReadHalf<OwnerTransport>>>,
write: tokio::io::WriteHalf<OwnerTransport>,
peer: Arc<AuthorizedPeer>,
receipt: RuntimeOwnerReceipt,
routing: Option<crate::RuntimeOwnerRouting>,
frontend: crate::daemon_socket::AttachFrontend,
output_failed: bool,
native_initialized: bool,
pending_initialize: Option<RequestId>,
}
/// Host startup is the only caller allowed to acquire the Runtime lease.
@@ -562,13 +573,20 @@ mod platform {
.is_none_or(|expected| expected == &receipt),
"selected owner changed before frontend admission"
);
let (read, mut write) = tokio::io::split(stream);
let (read, write) = tokio::io::split(stream);
let transport = crate::websocket::connect(read, write).await?;
let (read, mut write) = tokio::io::split(transport);
let mut replies = BoundedLines::new(BufReader::new(read));
let attach_id = uuid::Uuid::new_v4().to_string();
let attach = json!({"jsonrpc":"2.0","id":attach_id,"method":"daemon/attach","params":{
"client":{"name":"codewhale-stdio","version":env!("CARGO_PKG_VERSION"),"pid":std::process::id()},
"mode":"attach","frontend":frontend,"listener":listener,"scope":scope,"acp_model":acp_model,"expect_daemon_version":env!("CARGO_PKG_VERSION"),"expect_owner":receipt
}});
let attach = json!(RpcCall {
id: Some(RequestId::String(attach_id.clone())),
method: "daemon/attach".into(),
params: json!({
"client":{"name":"codewhale-stdio","version":env!("CARGO_PKG_VERSION"),"pid":std::process::id()},
"mode":"attach","frontend":frontend,"listener":listener,"scope":scope,"acp_model":acp_model,"expect_daemon_version":env!("CARGO_PKG_VERSION"),"expect_owner":receipt
}),
trace: None
});
tokio::time::timeout(
Duration::from_secs(5),
write_stdio_line(&mut write, &attach),
@@ -607,6 +625,8 @@ mod platform {
routing,
frontend,
output_failed: false,
native_initialized: false,
pending_initialize: None,
})))
}
@@ -623,7 +643,59 @@ mod platform {
!self.output_failed,
"owner transport write previously failed; outcomes uncertain, not replayed"
);
let frame = json!({"jsonrpc":"2.0","id":id,"method":method,"params":params});
if self.frontend == crate::daemon_socket::AttachFrontend::Acp {
return self
.send_frame(json!({"jsonrpc":"2.0","id":id,"method":method,"params":params}))
.await;
}
let id: RequestId = serde_json::from_value(id)?;
anyhow::ensure!(
self.pending_initialize.is_none(),
"receive the pending initialize acknowledgement before sending another request"
);
if self.frontend == crate::daemon_socket::AttachFrontend::Control
&& !self.native_initialized
&& method != "initialize"
{
let initialize_id = RequestId::String(format!(
"codewhale-native-initialize-{}",
uuid::Uuid::new_v4()
));
self.send_call(RpcCall { id: Some(initialize_id.clone()), method: "initialize".into(), params: json!({"clientInfo":{"name":"codewhale-native-control","version":env!("CARGO_PKG_VERSION")}}), trace: None }).await?;
self.pending_initialize = Some(initialize_id.clone());
let acknowledged: Result<()> = async {
let response = tokio::time::timeout(Duration::from_secs(5), self.recv()).await.context("owner initialize response deadline expired; connection outcome uncertain")??.context("owner input closed before initialize acknowledgement")?;
anyhow::ensure!(response.get("id") == Some(&json!(initialize_id)) && response.get("error").is_none() && response.get("result").is_some(), "owner initialize acknowledgement mismatch or refusal");
Ok(())
}.await;
if let Err(error) = acknowledged {
self.output_failed = true;
return Err(error);
}
self.native_initialized = true;
}
let pending = (self.frontend == crate::daemon_socket::AttachFrontend::Control
&& method == "initialize"
&& !self.native_initialized)
.then(|| id.clone());
self.send_call(RpcCall {
id: Some(id),
method: method.to_string(),
params,
trace: None,
})
.await?;
self.pending_initialize = pending;
Ok(())
}
async fn send_call(&mut self, request: RpcCall) -> Result<()> {
self.send_frame(json!(request)).await
}
async fn send_frame(&mut self, frame: Value) -> Result<()> {
anyhow::ensure!(
!self.output_failed,
"owner transport write previously failed; outcomes uncertain, not replayed"
);
let bytes = serialize_control_frame(&frame)?;
let check = self.peer.clone();
owner_work(move || check.check()).await?;
@@ -647,7 +719,36 @@ mod platform {
};
let check = self.peer.clone();
owner_work(move || check.check()).await?;
serde_json::from_str(&line).map(Some).map_err(Into::into)
let value: Value = serde_json::from_str(&line)?;
if let Some(id) = self.pending_initialize.as_ref()
&& value.get("id") == Some(&json!(id))
{
let response = serde_json::from_value::<RpcResponse>(value.clone());
let accepted = response
.ok()
.filter(|_| value.get("error").is_none())
.and_then(|response| {
serde_json::from_value::<InitializeResponse>(response.result).ok()
})
.is_some_and(|response| response.codex_home.is_absolute());
let rejected = serde_json::from_value::<RpcErrorResponse>(value.clone()).is_ok();
if !accepted && !rejected {
self.output_failed = true;
anyhow::bail!("initialize acknowledgement does not match its typed response");
}
self.native_initialized = accepted;
self.pending_initialize = None;
if accepted {
self.send_call(RpcCall {
id: None,
method: "initialized".into(),
params: Value::Null,
trace: None,
})
.await?;
}
}
Ok(Some(value))
}
pub async fn forward<I, O>(mut self, input: I, output: O) -> Result<()>
where
@@ -674,8 +775,7 @@ mod platform {
None => {
input_open=false;
tokio::time::timeout(Duration::from_secs(5), async {
write_stdio_line(&mut self.write,&json!({"jsonrpc":"2.0","method":"daemon/input_closed","params":{}})).await?;
self.write.shutdown().await?;
write_stdio_line(&mut self.write,&json!(RpcNotification { method:"daemon/input_closed".into(), params:Some(json!({})) })).await?;
Ok::<(), anyhow::Error>(())
}).await.context("owner input-close write outcome uncertain, not replayed")??;
}
@@ -688,9 +788,10 @@ mod platform {
}
Some(line) => match parse_stdio_line(&line) {
ParsedStdioLine::Blank => {},
ParsedStdioLine::Rejected(response) => write_stdio_line(&mut output, &response).await?,
ParsedStdioLine::Ignored => {},
ParsedStdioLine::Response(response) => self.send_frame(response).await?,
ParsedStdioLine::Request(request) => {
self.send(request.id.unwrap_or(Value::Null),&request.method,request.params).await?;
self.send_call(request).await?;
}
}
},
+80 -35
View File
@@ -3,10 +3,10 @@
//! The desktop shell attaches to a long-lived `codewhale app-server --socket`
//! daemon over a local socket instead of a TCP port: local multi-client,
//! peer-credential auth, nothing to firewall (CORE-PROTOCOL spec §5). The
//! wire is *identical* to the `--stdio` transport — newline-delimited
//! JSON-RPC 2.0 driven by the same `crate::stdio::run_stdio_loop` — with exactly
//! one addition in front of it: a `daemon/attach` handshake that establishes
//! who this client is and whether it owns the daemon.
//! wire carries RPC objects in WebSocket text messages; process stdio uses
//! JSONL. Both use `crate::stdio::run_stdio_loop` after the private channel's
//! WebSocket upgrade and `daemon/attach` handshake establish the connection
//! and whether this client owns the daemon.
//!
//! Windows derives a local named-pipe endpoint from the selected home and
//! current principal. It shares the same dispatcher and ownership handshake;
@@ -145,7 +145,7 @@ use crate::stdio::{
write_stdio_line,
};
use crate::{
AppState, AppTransport, JsonRpcError, ShutdownAuthority, StdioLoopExit, StdioLoopPolicy,
AppState, AppTransport, DispatchError, ShutdownAuthority, StdioLoopExit, StdioLoopPolicy,
jsonrpc_error, jsonrpc_result, params_or_object, parse_params,
};
use anyhow::Result;
@@ -153,7 +153,7 @@ use serde_json::{Value, json};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use tokio::io::{AsyncBufRead, AsyncWrite, BufReader};
use tokio::io::{AsyncBufRead, AsyncWrite, AsyncWriteExt as _, BufReader};
use tokio::sync::watch;
use tokio::task::JoinSet;
@@ -297,13 +297,12 @@ where
};
let request = match parse_stdio_line(&line) {
ParsedStdioLine::Blank => continue,
ParsedStdioLine::Rejected(response) => {
write_stdio_line(writer, &response).await?;
continue;
}
ParsedStdioLine::Ignored | ParsedStdioLine::Response(_) => continue,
ParsedStdioLine::Request(request) => request,
};
let id = request.id.clone();
let Some(id) = request.id.clone() else {
continue;
};
match request.method.as_str() {
"healthz" | "app/healthz" => {
let response = match dispatch_stdio_request_with_writer(
@@ -312,6 +311,7 @@ where
&request.method,
request.params,
AppTransport::Socket,
None,
)
.await
{
@@ -330,7 +330,7 @@ where
other => {
write_stdio_line(
writer,
&jsonrpc_error(id, JsonRpcError::attach_required(other)),
&jsonrpc_error(id, DispatchError::attach_required(other)),
)
.await?;
}
@@ -341,7 +341,7 @@ where
async fn attach(
context: &ConnectionContext,
params: Value,
) -> Result<(Value, ConnectionGuard), JsonRpcError> {
) -> Result<(Value, ConnectionGuard), DispatchError> {
let params: AttachParams = parse_params(params_or_object(params))?;
if params.frontend == AttachFrontend::Acp
&& (context.state.owner_frontend.is_none()
@@ -351,21 +351,21 @@ async fn attach(
.as_ref()
.is_some_and(|routing| routing.acp))
{
return Err(JsonRpcError::invalid_params(
return Err(DispatchError::invalid_params(
"held owner has no narrowed ACP projection; cross-profile attachment refused",
));
}
if (params.frontend == AttachFrontend::Listener) != params.listener.is_some() {
return Err(JsonRpcError::invalid_params(
return Err(DispatchError::invalid_params(
"listener selection does not match frontend",
));
}
if let Some(listener) = params.listener.as_ref() {
listener.validate_bounds().map_err(|_| {
JsonRpcError::invalid_params("selected frontend input exceeds its bounds")
DispatchError::invalid_params("selected frontend input exceeds its bounds")
})?;
if context.state.owner_frontend.is_none() {
return Err(JsonRpcError::invalid_params(
return Err(DispatchError::invalid_params(
"held owner has no selected listener projection",
));
}
@@ -374,13 +374,13 @@ async fn attach(
&& (params.frontend == AttachFrontend::Listener || context.state.owner_frontend.is_none())
|| params.acp_model.is_some() && params.frontend != AttachFrontend::Acp
{
return Err(JsonRpcError::invalid_params(
return Err(DispatchError::invalid_params(
"selected scope does not match captured frontend",
));
}
if let Some(scope) = params.scope.as_ref() {
scope.validate_bounds().map_err(|_| {
JsonRpcError::invalid_params("selected frontend scope exceeds its bounds")
DispatchError::invalid_params("selected frontend scope exceeds its bounds")
})?;
}
if params
@@ -388,27 +388,27 @@ async fn attach(
.as_ref()
.is_some_and(|model| model.trim().is_empty() || model.len() > 1024)
{
return Err(JsonRpcError::invalid_params("invalid selected ACP model"));
return Err(DispatchError::invalid_params("invalid selected ACP model"));
}
if let Some(owner) = context.owner.as_ref() {
if params.expect_owner.as_ref() != Some(owner) {
return Err(JsonRpcError::invalid_params(
return Err(DispatchError::invalid_params(
"captured owner generation or store does not match",
));
}
if params.mode == AttachMode::Claim {
return Err(JsonRpcError::not_daemon_owner());
return Err(DispatchError::not_daemon_owner());
}
}
if params.client.name.trim().is_empty() {
return Err(JsonRpcError::invalid_params(
return Err(DispatchError::invalid_params(
"client.name must not be empty",
));
}
if let Some(expected) = params.expect_daemon_version.as_deref()
&& expected != context.info.version
{
return Err(JsonRpcError::daemon_version_skew(
return Err(DispatchError::daemon_version_skew(
expected,
context.info.version,
));
@@ -432,13 +432,13 @@ async fn attach(
frontend
.validate_selection(&selection)
.await
.map_err(|error| JsonRpcError::invalid_params(error.to_string()))?;
.map_err(|error| DispatchError::invalid_params(error.to_string()))?;
}
}
let mut registry = context
.registry
.lock()
.map_err(|_| JsonRpcError::internal("daemon connection registry poisoned"))?;
.map_err(|_| DispatchError::internal("daemon connection registry poisoned"))?;
let id = registry.register(params.client.clone());
let role = match params.mode {
AttachMode::Attach => AttachRole::Attached,
@@ -447,8 +447,8 @@ async fn attach(
Err(owner) => {
registry.remove(id);
let owner = serde_json::to_value(owner)
.map_err(|err| JsonRpcError::internal(err.to_string()))?;
return Err(JsonRpcError::daemon_already_claimed(&owner));
.map_err(|err| DispatchError::internal(err.to_string()))?;
return Err(DispatchError::daemon_already_claimed(&owner));
}
},
};
@@ -543,7 +543,7 @@ pub(crate) async fn connection_context(
pub(crate) async fn run_authorized_connection<R, W>(
context: ConnectionContext,
read: R,
mut writer: W,
writer: W,
peer: AuthorizedPeer,
) where
R: tokio::io::AsyncRead + Unpin + Send + 'static,
@@ -554,6 +554,14 @@ pub(crate) async fn run_authorized_connection<R, W>(
if owner_work(move || check.check()).await.is_err() {
return;
}
let transport = match crate::websocket::accept(read, writer).await {
Ok(transport) => transport,
Err(error) => {
tracing::debug!(%error, "private WebSocket upgrade refused");
return;
}
};
let (read, mut writer) = tokio::io::split(transport);
let mut lines = crate::stdio::BoundedLines::new(BufReader::new(read));
let guard = match tokio::time::timeout(
Duration::from_secs(5),
@@ -597,12 +605,13 @@ pub(crate) async fn run_authorized_connection<R, W>(
selection,
context.state.clone(),
Box::new(lines.into_inner()),
Box::new(writer),
Box::new(&mut writer),
)
.await
{
tracing::debug!(%error,"captured frontend connection ended");
}
let _ = tokio::time::timeout(Duration::from_secs(5), writer.shutdown()).await;
return;
}
let policy = StdioLoopPolicy {
@@ -612,7 +621,16 @@ pub(crate) async fn run_authorized_connection<R, W>(
AttachRole::Attached => ShutdownAuthority::Denied,
},
};
match run_stdio_loop(&context.state, lines, writer, policy, Some((guard, peer))).await {
let result = run_stdio_loop(
&context.state,
lines,
&mut writer,
policy,
Some((guard, peer)),
)
.await;
let _ = tokio::time::timeout(Duration::from_secs(5), writer.shutdown()).await;
match result {
Ok(StdioLoopExit::Shutdown) => context.shutdown.trigger(),
Ok(StdioLoopExit::InputClosed) => {}
Err(error) => tracing::debug!(%error,"local control connection ended"),
@@ -1176,6 +1194,33 @@ mod platform {
_held: Arc<()>,
}
impl crate::RuntimeOwnerFrontend for FrontendLifetime {
fn execute(
&self,
_request: crate::RuntimeOwnerRequest,
) -> std::pin::Pin<
Box<
dyn std::future::Future<Output = anyhow::Result<serde_json::Value>> + Send + '_,
>,
> {
Box::pin(async {
anyhow::bail!("this captured frontend admits no ordinary native service")
})
}
fn events(
&self,
_thread_id: String,
_since_seq: u64,
) -> std::pin::Pin<
Box<
dyn std::future::Future<Output = anyhow::Result<crate::RuntimeOwnerEventStream>>
+ Send
+ '_,
>,
> {
Box::pin(async {
anyhow::bail!("this captured frontend admits no ordinary native observation")
})
}
fn validate_selection<'a>(
&'a self,
_selection: &'a crate::RuntimeOwnerFrontendSelection,
@@ -1183,13 +1228,13 @@ mod platform {
{
Box::pin(async { anyhow::bail!("lifetime-only frontend cannot admit a command") })
}
fn serve(
&self,
fn serve<'a>(
&'a self,
_selection: crate::RuntimeOwnerFrontendSelection,
_compatibility: AppState,
_input: Box<dyn tokio::io::AsyncBufRead + Send + Unpin>,
_output: Box<dyn tokio::io::AsyncWrite + Send + Unpin>,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + Send + '_>>
_output: Box<dyn tokio::io::AsyncWrite + Send + Unpin + 'a>,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + Send + 'a>>
{
Box::pin(async { anyhow::bail!("lifetime-only frontend cannot serve a command") })
}
File renamed without changes.
File diff suppressed because it is too large. Load diff
+7 -7
View File
@@ -1,7 +1,7 @@
use crate::config_rpc::{CONFIG_LOAD_ERROR, CONFIG_SAVE_ERROR, process_app_request};
use crate::turns::{handle_thread_request, run_http_thread_message, run_prompt_turn};
use crate::{
AppServerOptions, AppState, AppTransport, DEFAULT_CORS_ORIGINS, JsonRpcError,
AppServerOptions, AppState, AppTransport, DEFAULT_CORS_ORIGINS, DispatchError,
MAX_HTTP_BODY_BYTES, RUNTIME_UNAVAILABLE_CODE, THREAD_NOT_FOUND_CODE, chat_completions,
legacy_deepseek_compat,
};
@@ -91,8 +91,8 @@ pub(crate) async fn healthz() -> Json<Value> {
/// Deliberately *not* a success-shaped payload with the error stuffed into a
/// content field: a client must be able to tell "the model said this" from
/// "nothing ran".
pub(crate) fn http_error_from_jsonrpc(err: JsonRpcError) -> (StatusCode, Json<Value>) {
let (status, code) = match err.code {
pub(crate) fn http_error_from_jsonrpc(err: DispatchError) -> (StatusCode, Json<Value>) {
let (status, code) = match err.wire.code {
-32600 | -32602 => (StatusCode::BAD_REQUEST, "invalid_request"),
THREAD_NOT_FOUND_CODE => (StatusCode::NOT_FOUND, "thread_not_found"),
RUNTIME_UNAVAILABLE_CODE => (StatusCode::SERVICE_UNAVAILABLE, "runtime_unavailable"),
@@ -103,9 +103,9 @@ pub(crate) fn http_error_from_jsonrpc(err: JsonRpcError) -> (StatusCode, Json<Va
Json(json!({
"error": {
"code": code,
"jsonrpc_code": err.code,
"message": err.message,
"data": err.data,
"jsonrpc_code": err.wire.code,
"message": err.wire.message,
"data": err.wire.data,
}
})),
)
@@ -156,7 +156,7 @@ pub(crate) async fn prompt_handler(
Json(req): Json<PromptRequest>,
) -> Response {
let mut sink = tokio::io::sink();
match run_prompt_turn(&state, &mut sink, req).await {
match run_prompt_turn(&state, &mut sink, req, None).await {
Ok(res) => (StatusCode::OK, Json(res)).into_response(),
Err(err) => http_error_from_jsonrpc(err).into_response(),
}
+217 -177
View File
@@ -1,10 +1,21 @@
use crate::bridge::install_rustls_crypto_provider;
pub mod acp_server;
pub mod eval;
pub mod exec_agent;
pub mod mcp_server;
pub mod remote_control;
pub mod remote_setup;
pub mod runtime_api;
pub mod runtime_chat_relay;
use crate::http::{app_router, resolve_auth_token};
use crate::stdio::{BoundedLines, run_stdio_loop};
use anyhow::Context as _;
use anyhow::{Result, anyhow};
use axum::http::{Method, StatusCode};
use codewhale_agent::ModelRegistry;
use codewhale_app_server_protocol::rpc::{
RequestId, RpcCall, RpcErrorDetails, RpcErrorResponse, RpcResponse,
};
#[cfg(test)]
use codewhale_app_server_protocol::runtime::MAX_RUNTIME_IMAGE_BODY_BYTES;
use codewhale_app_server_protocol::runtime::RuntimeImageInput;
@@ -29,14 +40,18 @@ use tokio::io::{AsyncBufRead, AsyncWrite, BufReader};
use tokio::sync::{Mutex, RwLock};
use uuid::Uuid;
mod chat_completions;
mod connection;
mod thread_control;
mod websocket;
pub use codewhale_protocol::{
ThreadListParams, ThreadReadParams, ThreadRequest, ThreadResponse, ThreadSetNameParams,
};
pub use thread_control::{ThreadControlSelection, request_thread_control};
#[cfg(test)]
mod bridge;
mod config_rpc;
pub mod http;
mod owner_calls;
pub mod stdio;
mod turns;
@@ -66,6 +81,7 @@ mod legacy_deepseek_compat {
/// Upper bound on JSON request bodies accepted by the HTTP app-server.
const MAX_HTTP_BODY_BYTES: usize = 16 * 1024 * 1024;
#[cfg(test)]
const MAX_SSE_FRAME_BYTES: usize = 16 * 1024 * 1024;
const DEFAULT_CORS_ORIGINS: &[&str] = &[
@@ -201,20 +217,99 @@ pub enum RuntimeOwnerFrontendSelection {
Listener(RuntimeListenerSelection),
}
pub type RuntimeOwnerEventStream =
std::pin::Pin<Box<dyn futures_util::Stream<Item = RuntimeOwnerEventFrame> + Send>>;
pub struct RuntimeOwnerEventFrame {
pub event: String,
pub data: Value,
pub sequence: Option<u64>,
}
#[derive(Debug, Clone)]
pub enum RuntimeOwnerRequest {
Capabilities,
Providers,
ProviderModels {
provider: String,
provider_id: Option<String>,
cursor: Option<String>,
},
ReloadConfig,
CreateThread(Value),
ListThreads {
include_archived: bool,
limit: usize,
},
RunningThreads,
LoadedThreads,
GetThread {
thread_id: String,
},
UpdateThread {
thread_id: String,
body: Value,
},
GetGoal {
thread_id: String,
},
ClearGoal {
thread_id: String,
},
HistorySnapshot {
thread_id: String,
},
HistoryImport(codewhale_protocol::CanonicalHistoryImportRequest),
HistoryMutate(codewhale_protocol::CanonicalThreadMutationRequest),
HistoryLookup(codewhale_protocol::CanonicalThreadOperationLookup),
HistoryRecover(codewhale_protocol::CanonicalThreadOperationRecovery),
StartTurn {
thread_id: String,
body: Value,
},
InterruptTurn {
thread_id: String,
turn_id: String,
},
DecideApproval {
approval_id: String,
decision: codewhale_engine::runtime_threads::manager::ExternalApprovalDecision,
},
}
#[derive(Debug, thiserror::Error)]
#[error("Runtime owner returned {status}: {message}")]
pub struct RuntimeOwnerFailure {
pub status: u16,
pub message: String,
pub code: Option<String>,
}
/// Captured Runtime-owned IO projection. Implementations use the held manager
/// and existing routers; this port owns neither a store nor a turn controller.
pub trait RuntimeOwnerFrontend: Send + Sync {
fn execute(
&self,
request: RuntimeOwnerRequest,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Value>> + Send + '_>>;
fn events(
&self,
thread_id: String,
since_seq: u64,
) -> std::pin::Pin<
Box<dyn std::future::Future<Output = Result<RuntimeOwnerEventStream>> + Send + '_>,
>;
fn validate_selection<'a>(
&'a self,
selection: &'a RuntimeOwnerFrontendSelection,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + Send + 'a>>;
fn serve(
&self,
fn serve<'a>(
&'a self,
selection: RuntimeOwnerFrontendSelection,
compatibility: AppState,
input: Box<dyn tokio::io::AsyncBufRead + Send + Unpin>,
output: Box<dyn AsyncWrite + Send + Unpin>,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + Send + '_>>;
output: Box<dyn AsyncWrite + Send + Unpin + 'a>,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + Send + 'a>>;
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
@@ -235,11 +330,7 @@ pub struct RuntimeOwnerRouting {
pub acp_only: bool,
}
/// Cached app-server→runtime bridge handle.
///
/// The outer [`AppState::runtime_bridge`] mutex guards only the cache slot;
/// this inner mutex serializes traffic and event cursors for the captured
/// canonical owner. It never creates or replaces an execution process.
#[cfg(test)]
type SharedRuntimeBridge = Arc<Mutex<RuntimeBridge>>;
#[derive(Clone)]
@@ -255,33 +346,27 @@ pub struct AppState {
runtime: Arc<RwLock<Runtime>>,
registry: ModelRegistry,
auth_token: Option<String>,
/// Cached bridge to the real runtime API. Shared by every surface that
/// executes a turn — stdio `thread/message`, HTTP `/thread` messages, and
/// both `/prompt` transports — because there is exactly one turn engine.
#[cfg(test)]
runtime_bridge: Arc<Mutex<Option<SharedRuntimeBridge>>>,
runtime_catalog_transport: Option<RuntimeCatalogTransport>,
/// Client-facing thread key → durable runtime thread id.
///
/// Runtime threads are persisted by the captured owner's store. Durable
/// aliases keep their exact target across frontend detach/reconnect;
/// withdrawing a bridge never remints a replacement thread.
/// Callers serialize traffic on the bridge mutex.
#[cfg(test)]
runtime_thread_map: Arc<Mutex<HashMap<String, String>>>,
stdio_thread_hints: Arc<Mutex<HashMap<String, RuntimeThreadHint>>>,
/// Turns currently streaming over stdio, keyed by stdio thread id.
///
/// Deliberately kept *outside* the bridge mutex: a streaming turn holds
/// that mutex for its entire duration, so anything reachable only through
/// it cannot be used to stop the turn. This holds its own copy of what an
/// interrupt needs, so a cancel never waits on the turn it is cancelling.
in_flight_turns: Arc<Mutex<HashMap<String, InFlightTurn>>>,
}
/// Everything needed to interrupt a running turn without the bridge lock.
#[derive(Debug, Clone)]
#[derive(Clone)]
enum TurnExecutionOwner {
Native(Arc<dyn RuntimeOwnerFrontend>),
#[cfg(test)]
HistoricalHttp {
base_url: String,
auth_token: Option<String>,
},
}
#[derive(Clone)]
struct InFlightTurn {
base_url: String,
auth_token: Option<String>,
owner: TurnExecutionOwner,
/// Thread id as the *runtime* knows it, not the stdio-facing id.
runtime_thread_id: String,
turn_id: String,
@@ -289,17 +374,6 @@ struct InFlightTurn {
type TurnRegistry = Arc<Mutex<HashMap<String, InFlightTurn>>>;
#[derive(Debug, Deserialize)]
struct JsonRpcRequest {
#[serde(default)]
jsonrpc: Option<String>,
#[serde(default)]
id: Option<Value>,
method: String,
#[serde(default)]
params: Value,
}
/// Server error: the app-server could not reach the runtime that executes
/// turns. Kept in the JSON-RPC implementation-defined server range
/// (-32000..-32099) alongside `thread_not_found` (-32004).
@@ -330,10 +404,8 @@ const DAEMON_VERSION_SKEW_CODE: i64 = -32013;
const ALREADY_ATTACHED_CODE: i64 = -32014;
#[derive(Debug)]
struct JsonRpcError {
code: i64,
message: String,
data: Option<Value>,
struct DispatchError {
wire: RpcErrorDetails,
transport_error: Option<anyhow::Error>,
}
@@ -343,31 +415,24 @@ struct StdioDispatchResult {
should_exit: bool,
}
#[derive(Clone)]
struct RuntimeCatalogTransport {
bridge: std::sync::Weak<Mutex<RuntimeBridge>>,
base_url: String,
client: reqwest::Client,
auth_token: Option<String>,
}
#[cfg(test)]
struct RuntimeBridge {
base_url: String,
client: reqwest::Client,
auth_token: Option<String>,
#[cfg(test)]
child: Option<Child>,
/// Captured owner SSE cursors, keyed by Runtime thread identity.
#[cfg(test)]
last_seq_by_thread: HashMap<String, u64>,
}
#[cfg(test)]
impl std::fmt::Debug for RuntimeBridge {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("RuntimeBridge")
.field("base_url", &self.base_url)
.field("authenticated", &self.auth_token.is_some())
.field("last_seq_by_thread", &self.last_seq_by_thread)
.finish_non_exhaustive()
}
}
@@ -406,7 +471,7 @@ enum AppTransport {
Http,
Stdio,
/// Unix-domain-socket daemon transport (`daemon_socket`). Speaks the
/// stdio JSON-RPC protocol verbatim after a `daemon/attach` handshake.
/// same RPC objects as stdio inside WebSocket text messages after attach.
Socket,
}
@@ -592,27 +657,6 @@ pub async fn bind_runtime_frontends(
);
let mut state =
build_state_off_runtime(config_path, auth_token.clone(), AppTransport::Socket).await?;
install_rustls_crypto_provider();
let endpoint = routing.endpoint;
let address = match endpoint.ip() {
std::net::IpAddr::V4(ip) if ip.is_unspecified() => {
SocketAddr::from((std::net::Ipv4Addr::LOCALHOST, endpoint.port()))
}
std::net::IpAddr::V6(ip) if ip.is_unspecified() => {
SocketAddr::from((std::net::Ipv6Addr::LOCALHOST, endpoint.port()))
}
_ => endpoint,
};
let bridge = RuntimeBridge {
base_url: format!("http://{address}"),
client: codewhale_release::platform_http_client_builder()
.redirect(reqwest::redirect::Policy::none())
.build()?,
auth_token,
#[cfg(test)]
child: None,
last_seq_by_thread: HashMap::new(),
};
state.captured_owner = Some(owner.clone());
anyhow::ensure!(
routing.acp == owner_frontend.is_some() && (!routing.acp_only || routing.acp),
@@ -621,19 +665,6 @@ pub async fn bind_runtime_frontends(
state.frontend_workspace = routing.workspace.clone();
state.captured_routing = Some(routing);
state.owner_frontend = owner_frontend;
let (base_url, client, auth_token) = (
bridge.base_url.clone(),
bridge.client.clone(),
bridge.auth_token.clone(),
);
let bridge = Arc::new(Mutex::new(bridge));
state.runtime_catalog_transport = Some(RuntimeCatalogTransport {
bridge: Arc::downgrade(&bridge),
base_url,
client,
auth_token,
});
*state.runtime_bridge.lock().await = Some(bridge);
let daemon = daemon_socket::bind_captured_owner(state.clone(), owner, recovery).await?;
Ok((daemon, state))
}
@@ -664,11 +695,11 @@ pub async fn run_owned_acp(state: AppState) -> Result<()> {
/// The existing charged socket connection invokes the same priority-aware
/// dispatcher after its captured Runtime has admitted the selected scope.
pub async fn run_guest_control(
pub async fn run_guest_control<'a>(
mut state: AppState,
workspace: PathBuf,
input: Box<dyn AsyncBufRead + Send + Unpin>,
output: Box<dyn AsyncWrite + Send + Unpin>,
output: Box<dyn AsyncWrite + Send + Unpin + 'a>,
) -> Result<()> {
anyhow::ensure!(
state
@@ -689,7 +720,9 @@ pub async fn run_guest_control(
/// Logical write EOF for duplex transports. This is only a per-connection
/// lifecycle notification; it carries no tool, turn or host-shutdown authority.
pub fn is_control_input_closed(message: &Value) -> bool {
message["jsonrpc"] == "2.0"
message
.get("jsonrpc")
.is_none_or(|version| version == "2.0")
&& message.get("id").is_none()
&& message["method"] == "daemon/input_closed"
&& message["params"]
@@ -798,8 +831,9 @@ fn build_state_with_transport(
runtime: Arc::new(RwLock::new(runtime)),
registry,
auth_token,
#[cfg(test)]
runtime_bridge: Arc::new(Mutex::new(None)),
runtime_catalog_transport: None,
#[cfg(test)]
runtime_thread_map: Arc::new(Mutex::new(HashMap::new())),
stdio_thread_hints: Arc::new(Mutex::new(HashMap::new())),
in_flight_turns: Arc::new(Mutex::new(HashMap::new())),
@@ -810,70 +844,58 @@ fn params_or_object(params: Value) -> Value {
if params.is_null() { json!({}) } else { params }
}
fn parse_params<T: DeserializeOwned>(params: Value) -> std::result::Result<T, JsonRpcError> {
serde_json::from_value(params).map_err(|err| JsonRpcError::invalid_params(err.to_string()))
fn parse_params<T: DeserializeOwned>(params: Value) -> std::result::Result<T, DispatchError> {
serde_json::from_value(params).map_err(|err| DispatchError::invalid_params(err.to_string()))
}
fn jsonrpc_result(id: Option<Value>, result: Value) -> Value {
json!({
"jsonrpc": "2.0",
"id": id.unwrap_or(Value::Null),
"result": result
fn jsonrpc_result(id: RequestId, result: Value) -> Value {
json!(RpcResponse { id, result })
}
fn jsonrpc_error(id: RequestId, err: DispatchError) -> Value {
json!(RpcErrorResponse {
id,
error: err.wire
})
}
fn jsonrpc_error(id: Option<Value>, err: JsonRpcError) -> Value {
json!({
"jsonrpc": "2.0",
"id": id.unwrap_or(Value::Null),
"error": {
"code": err.code,
"message": err.message,
"data": err.data
}
})
}
impl JsonRpcError {
fn parse_error(message: impl Into<String>) -> Self {
Self {
transport_error: None,
code: -32700,
message: message.into(),
data: None,
}
}
impl DispatchError {
fn invalid_request(message: impl Into<String>) -> Self {
Self {
wire: RpcErrorDetails {
code: -32600,
message: message.into(),
data: None,
},
transport_error: None,
code: -32600,
message: message.into(),
data: None,
}
}
fn method_not_found(method: &str) -> Self {
Self {
wire: RpcErrorDetails {
code: -32601,
message: format!("unsupported method: {method}"),
data: None,
},
transport_error: None,
code: -32601,
message: format!("unsupported method: {method}"),
data: None,
}
}
fn invalid_params(message: impl Into<String>) -> Self {
Self {
wire: RpcErrorDetails {
code: -32602,
message: message.into(),
data: None,
},
transport_error: None,
code: -32602,
message: message.into(),
data: None,
}
}
fn unsupported_interactive_execution() -> Self {
Self {
transport_error: None,
wire: RpcErrorDetails {
code: -32602,
message: "Compatibility transports cannot answer approvals or clarification requests. Use codewhale app-server --http and /v1/* for interactive turns and goal execution; tool-free turn requests require explicit allowed_tools: [].".to_string(),
data: Some(json!({
@@ -882,6 +904,8 @@ impl JsonRpcError {
"allowed_tools_required": [],
"goal_execution": false,
})),
},
transport_error: None,
}
}
@@ -891,35 +915,41 @@ impl JsonRpcError {
fn runtime_unavailable(message: impl Into<String>) -> Self {
let message = message.into();
Self {
wire: RpcErrorDetails {
code: RUNTIME_UNAVAILABLE_CODE,
message: message.clone(),
data: Some(json!({
"error": "runtime_unavailable",
"detail": message,
})),
},
transport_error: None,
code: RUNTIME_UNAVAILABLE_CODE,
message: message.clone(),
data: Some(json!({
"error": "runtime_unavailable",
"detail": message,
})),
}
}
/// Server error (-32000..-32099): the named thread does not exist.
fn thread_not_found(thread_id: &str) -> Self {
Self {
wire: RpcErrorDetails {
code: THREAD_NOT_FOUND_CODE,
message: format!("thread not found: {thread_id}"),
data: Some(json!({
"error": "thread_not_found",
"thread_id": thread_id,
})),
},
transport_error: None,
code: THREAD_NOT_FOUND_CODE,
message: format!("thread not found: {thread_id}"),
data: Some(json!({
"error": "thread_not_found",
"thread_id": thread_id,
})),
}
}
fn internal(message: impl Into<String>) -> Self {
Self {
wire: RpcErrorDetails {
code: -32603,
message: message.into(),
data: None,
},
transport_error: None,
code: -32603,
message: message.into(),
data: None,
}
}
@@ -928,14 +958,16 @@ impl JsonRpcError {
#[cfg(any(unix, windows))]
fn attach_required(method: &str) -> Self {
Self {
wire: RpcErrorDetails {
code: ATTACH_REQUIRED_CODE,
message: format!("send daemon/attach before `{method}`"),
data: Some(json!({
"error": "attach_required",
"method": method,
"attach_method": daemon_socket::ATTACH_METHOD,
})),
},
transport_error: None,
code: ATTACH_REQUIRED_CODE,
message: format!("send daemon/attach before `{method}`"),
data: Some(json!({
"error": "attach_required",
"method": method,
"attach_method": daemon_socket::ATTACH_METHOD,
})),
}
}
@@ -943,24 +975,28 @@ impl JsonRpcError {
#[cfg(any(unix, windows))]
fn daemon_already_claimed(owner: &Value) -> Self {
Self {
wire: RpcErrorDetails {
code: DAEMON_ALREADY_CLAIMED_CODE,
message: "daemon already claimed by another client; attach with mode=attach"
.to_string(),
data: Some(json!({
"error": "daemon_already_claimed",
"owner": owner,
})),
},
transport_error: None,
code: DAEMON_ALREADY_CLAIMED_CODE,
message: "daemon already claimed by another client; attach with mode=attach"
.to_string(),
data: Some(json!({
"error": "daemon_already_claimed",
"owner": owner,
})),
}
}
/// Server error (-32000..-32099): only the owner may stop the daemon.
fn not_daemon_owner() -> Self {
Self {
wire: RpcErrorDetails {
code: NOT_DAEMON_OWNER_CODE,
message: "only the client that claimed this daemon may shut it down".to_string(),
data: Some(json!({ "error": "not_daemon_owner" })),
},
transport_error: None,
code: NOT_DAEMON_OWNER_CODE,
message: "only the client that claimed this daemon may shut it down".to_string(),
data: Some(json!({ "error": "not_daemon_owner" })),
}
}
@@ -969,26 +1005,30 @@ impl JsonRpcError {
#[cfg(any(unix, windows))]
fn daemon_version_skew(expected: &str, actual: &str) -> Self {
Self {
wire: RpcErrorDetails {
code: DAEMON_VERSION_SKEW_CODE,
message: format!(
"daemon version {actual} does not match the client's expected {expected}"
),
data: Some(json!({
"error": "daemon_version_skew",
"expected": expected,
"actual": actual,
})),
},
transport_error: None,
code: DAEMON_VERSION_SKEW_CODE,
message: format!(
"daemon version {actual} does not match the client's expected {expected}"
),
data: Some(json!({
"error": "daemon_version_skew",
"expected": expected,
"actual": actual,
})),
}
}
/// Server error (-32000..-32099): `daemon/attach` after attaching.
fn already_attached() -> Self {
Self {
wire: RpcErrorDetails {
code: ALREADY_ATTACHED_CODE,
message: "this connection is already attached".to_string(),
data: Some(json!({ "error": "already_attached" })),
},
transport_error: None,
code: ALREADY_ATTACHED_CODE,
message: "this connection is already attached".to_string(),
data: Some(json!({ "error": "already_attached" })),
}
}
}
@@ -240,11 +240,11 @@ impl McpServer {
let workspace = self.workspace.clone();
let skills_dir = self.skills_dir.clone();
let skills_config = self.skills_config.clone();
#[cfg(test)]
let env_ticket = crate::test_support::env_scope_ticket();
#[cfg(any(test, feature = "test-support"))]
let env_ticket = codewhale_engine::test_support::env_scope_ticket();
let discovery = tokio::task::spawn_blocking(move || {
#[cfg(test)]
let _membership = crate::test_support::join_env_scope(env_ticket);
#[cfg(any(test, feature = "test-support"))]
let _membership = codewhale_engine::test_support::join_env_scope(env_ticket);
let registry = codewhale_skills::discover_for_workspace(
&workspace,
Some(&skills_dir),
@@ -795,7 +795,7 @@ mod tests {
#[tokio::test]
async fn list_responses_omit_null_next_cursor() {
let _home = crate::test_support::SealedHome::new();
let _home = codewhale_engine::test_support::SealedHome::new();
let root = tempfile::tempdir().unwrap();
let workspace = root.path().join("workspace");
let skills_dir = root.path().join("skills");
+242
View File
@@ -0,0 +1,242 @@
use super::*;
use anyhow::bail;
pub(crate) fn captured(state: &AppState) -> Result<Arc<dyn RuntimeOwnerFrontend>> {
state
.owner_frontend
.clone()
.context("the captured Runtime native services are unavailable; refusing another owner")
}
pub(crate) async fn execute(
owner: &Arc<dyn RuntimeOwnerFrontend>,
request: RuntimeOwnerRequest,
) -> Result<Value> {
tokio::time::timeout(Duration::from_secs(30), owner.execute(request))
.await
.context("native owner deadline expired; outcome may be committed, no automatic replay")?
}
pub(crate) async fn runtime_catalog_models(
owner: &Arc<dyn RuntimeOwnerFrontend>,
provider: &str,
provider_id: Option<&str>,
) -> Result<Vec<Value>> {
let mut models = Vec::new();
let mut cursor = None;
let mut cursors = std::collections::HashSet::new();
let mut ids = std::collections::HashSet::new();
let mut total = None;
for _ in 0..40 {
let catalog = execute(
owner,
RuntimeOwnerRequest::ProviderModels {
provider: provider.to_owned(),
provider_id: provider_id.map(str::to_owned),
cursor: cursor.clone(),
},
)
.await?;
anyhow::ensure!(
catalog.get("provider").and_then(Value::as_str) == Some(provider)
&& catalog.get("model_provider_id").unwrap_or(&Value::Null) == &json!(provider_id),
"Runtime model catalog changed provider identity"
);
let count = catalog
.get("total")
.and_then(Value::as_u64)
.context("Runtime model catalog omitted its total")?;
anyhow::ensure!(
count <= 10_000 && total.is_none_or(|total| total == count),
"Runtime model catalog size changed or exceeds its limit"
);
total = Some(count);
let entries = catalog
.get("models")
.and_then(Value::as_array)
.context("Runtime model catalog omitted its models")?;
anyhow::ensure!(
entries.len() <= 250,
"Runtime model catalog page exceeds its limit"
);
for entry in entries {
let id = entry
.get("id")
.and_then(Value::as_str)
.filter(|id| !id.is_empty())
.context("Runtime model catalog contains an invalid model")?;
anyhow::ensure!(
ids.insert(id.to_string()),
"Runtime model catalog repeated a model"
);
models.push(entry.clone());
}
anyhow::ensure!(
models.len() as u64 <= count,
"Runtime model catalog exceeds its total"
);
match catalog.get("nextCursor") {
None | Some(Value::Null) => {
anyhow::ensure!(
models.len() as u64 == count,
"Runtime model catalog is incomplete"
);
return Ok(models);
}
Some(Value::String(next)) if !next.is_empty() && next.len() <= 1_368 => {
anyhow::ensure!(
!entries.is_empty() && models.len() < count as usize,
"Runtime model catalog continuation is invalid"
);
anyhow::ensure!(
cursors.insert(next.clone()),
"Runtime model catalog cursor repeated"
);
cursor = Some(next.clone());
}
_ => bail!("Runtime model catalog cursor is invalid"),
}
}
bail!("Runtime model catalog exceeded its page limit")
}
pub(crate) async fn runtime_app_models(state: &AppState) -> Result<Vec<Value>> {
let owner = captured(state)?;
let providers = execute(&owner, RuntimeOwnerRequest::Providers).await?;
let providers = providers
.get("providers")
.and_then(Value::as_array)
.context("Runtime provider catalog is invalid")?;
anyhow::ensure!(
providers.len() <= 256,
"Runtime provider catalog exceeds its limit"
);
let aliases = state.registry.list();
let mut models = Vec::new();
let mut routes = std::collections::HashSet::new();
for provider in providers {
if !provider
.get("has_model_catalog")
.and_then(Value::as_bool)
.context("Runtime provider omitted its catalog availability")?
{
continue;
}
let kind = provider
.get("id")
.and_then(Value::as_str)
.context("Runtime provider omitted its identity")?;
let parsed: codewhale_config::ProviderKind =
serde_json::from_value(json!(kind)).context("Runtime provider kind is unsupported")?;
anyhow::ensure!(
kind == parsed.as_str()
|| codewhale_config::descriptors::compatibility_for_id(kind)
.is_some_and(|compatibility| compatibility.kind == parsed),
"Runtime provider kind is not a recognized wire identity"
);
let provider_id = match provider.get("model_provider_id") {
None | Some(Value::Null) => None,
Some(Value::String(id)) if !id.is_empty() => Some(id.as_str()),
_ => bail!("Runtime configured provider identity is invalid"),
};
if !routes.insert((kind.to_string(), provider_id.map(str::to_string))) {
continue;
}
for mut model in runtime_catalog_models(&owner, kind, provider_id).await? {
let id = model
.get("id")
.and_then(Value::as_str)
.context("Runtime model identity is invalid")?;
let compatibility_aliases = aliases
.iter()
.find(|entry| {
parsed != codewhale_config::ProviderKind::Custom
&& provider_id.is_none_or(|identity| identity == kind)
&& entry.provider == parsed
&& entry.id == id
})
.map(|entry| entry.aliases.clone())
.unwrap_or_default();
let capabilities: codewhale_config::route::RouteCapabilities =
match model.pointer("/facts/capabilities") {
None | Some(Value::Null) => Default::default(),
Some(value) => serde_json::from_value(value.clone())
.context("Runtime model capabilities are invalid")?,
};
let row = model
.as_object_mut()
.context("Runtime model entry is invalid")?;
row.insert("provider".into(), serde_json::to_value(parsed)?);
row.insert("model_provider_id".into(), json!(provider_id));
row.insert("aliases".into(), json!(compatibility_aliases));
row.insert(
"supports_tools".into(),
json!(
capabilities.native_tool_calls
== codewhale_config::route::CapabilityState::Supported
),
);
row.insert(
"supports_reasoning".into(),
json!(
capabilities.reasoning == codewhale_config::route::CapabilityState::Supported
),
);
row.insert("capabilities".into(), serde_json::to_value(capabilities)?);
models.push(model);
anyhow::ensure!(
models.len() <= 100_000,
"Runtime model catalog exceeds the response limit"
);
}
}
Ok(models)
}
pub(crate) async fn require_output_limited_model(
owner: &Arc<dyn RuntimeOwnerFrontend>,
requested_model: Option<&str>,
) -> Result<()> {
let providers = execute(owner, RuntimeOwnerRequest::Providers).await?;
let current = providers
.get("current")
.and_then(Value::as_str)
.context("Runtime provider is unavailable")?;
let current_id = match providers.get("current_provider_id") {
None | Some(Value::Null) => None,
Some(Value::String(id)) if !id.is_empty() => Some(id.as_str()),
_ => bail!("Runtime configured provider identity is invalid"),
};
let provider = providers
.get("providers")
.and_then(Value::as_array)
.and_then(|providers| {
providers.iter().find(|provider| {
provider.get("id").and_then(Value::as_str) == Some(current)
&& provider.get("model_provider_id").and_then(Value::as_str) == current_id
})
})
.context("Runtime provider is unavailable")?;
let model = requested_model
.or_else(|| provider.get("default_model").and_then(Value::as_str))
.context("maxOutputTokens requires an exact model")?;
if model.trim().is_empty() || model.eq_ignore_ascii_case("auto") {
bail!("maxOutputTokens requires an exact model");
}
if !current
.bytes()
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
{
bail!("Runtime provider identity is invalid");
}
let catalog = runtime_catalog_models(owner, current, current_id).await?;
let entry = catalog
.iter()
.find(|entry| entry.get("id").and_then(Value::as_str) == Some(model))
.context("Output-limit support is unknown for the selected Runtime model")?;
anyhow::ensure!(
entry.get("output_token_limit").and_then(Value::as_str) == Some("supported"),
"The selected Runtime model does not support maxOutputTokens"
);
Ok(())
}
@@ -788,7 +788,7 @@ pub struct PendingRemoteApproval {
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Status {
pub enum Status {
#[default]
Off,
Connecting,
@@ -894,7 +894,7 @@ impl RemoteControlController {
/// fixes the lock order across processes: a competing workspace cannot
/// make us release an existing Runtime store owner and only later discover
/// that the saved session is permanently bound elsewhere.
pub(crate) fn prepare_remote_control_session_journal(
pub fn prepare_remote_control_session_journal(
&mut self,
dir: &Path,
target_ref: &str,
@@ -938,7 +938,7 @@ impl RemoteControlController {
}
}
pub(crate) fn configure_runtime_chat(
pub fn configure_runtime_chat(
&mut self,
config: codewhale_runtime::config::Config,
plugin_registry: std::sync::Arc<codewhale_plugins::PluginRegistry>,
@@ -1098,7 +1098,7 @@ impl RemoteControlController {
result
}
pub(crate) fn record_start_failure(&mut self, error: &str) {
pub fn record_start_failure(&mut self, error: &str) {
if matches!(self.status, Status::Off | Status::Failed)
&& !self
.ownership_blocked_until
@@ -1308,7 +1308,7 @@ impl RemoteControlController {
.any(|entry| entry.integrity)
}
pub(crate) fn runtime_chat_blocks_local_dispatch(&self) -> bool {
pub fn runtime_chat_blocks_local_dispatch(&self) -> bool {
self.has_durable_classic_lease()
|| self.pending_runtime_chat_configuration.is_some()
|| self.runtime_chat.as_ref().is_some_and(|host| {
@@ -1316,7 +1316,7 @@ impl RemoteControlController {
})
}
pub(crate) async fn workspace_restore_guard(
pub async fn workspace_restore_guard(
&self,
workspace: &Path,
) -> Result<Option<tokio::sync::OwnedRwLockWriteGuard<()>>, String> {
@@ -1326,8 +1326,8 @@ impl RemoteControlController {
}
}
#[cfg(test)]
pub(crate) fn block_runtime_chat_dispatch_for_tests(&mut self) {
#[cfg(any(test, feature = "test-support"))]
pub fn block_runtime_chat_dispatch_for_tests(&mut self) {
self.pending_runtime_chat_configuration = Some(PendingRuntimeChatConfiguration {
config: codewhale_runtime::config::Config::default(),
plugin_registry: std::sync::Arc::new(codewhale_plugins::PluginRegistry::empty(
@@ -1665,7 +1665,7 @@ impl RemoteControlController {
}
#[must_use]
pub(crate) fn status(&self) -> Status {
pub fn status(&self) -> Status {
self.status
}
@@ -1673,8 +1673,8 @@ impl RemoteControlController {
/// plane. Mirrors the shape the runner-lease parser installs (`Connected`
/// plus validated links) so `/rc link`/`/rc open` routing can be exercised
/// deterministically offline.
#[cfg(test)]
pub(crate) fn install_live_link_for_test(&mut self, run_url: &str, computer_url: Option<&str>) {
#[cfg(any(test, feature = "test-support"))]
pub fn install_live_link_for_test(&mut self, run_url: &str, computer_url: Option<&str>) {
self.status = Status::Connected;
self.status_detail = "test link".to_string();
self.links = RemoteLinks {
@@ -1788,8 +1788,8 @@ impl RemoteControlController {
/// Test-only: put the controller into the exact state a live connected
/// mirror with an attached run would be in, without a relay worker.
#[cfg(test)]
pub(crate) fn force_mirror_connected_for_tests(&mut self, run_id: &str, turn_id: &str) {
#[cfg(any(test, feature = "test-support"))]
pub fn force_mirror_connected_for_tests(&mut self, run_id: &str, turn_id: &str) {
self.status = Status::Connected;
self.status_detail = "web mirror connected".to_string();
self.attached_run_id = Some(run_id.to_string());
@@ -1799,8 +1799,8 @@ impl RemoteControlController {
});
}
#[cfg(test)]
pub(crate) fn queue_remote_event_for_tests(&mut self, event: RemoteEvent) {
#[cfg(any(test, feature = "test-support"))]
pub fn queue_remote_event_for_tests(&mut self, event: RemoteEvent) {
let (event_tx, event_rx) = mpsc::unbounded_channel();
event_tx.send(event).expect("test event receiver");
self.event_rx = Some(event_rx);
@@ -2243,7 +2243,7 @@ impl RemoteControlController {
});
}
pub(crate) async fn apply_runtime_chat_prompt(
pub async fn apply_runtime_chat_prompt(
&self,
command: &RuntimeChatPrompt,
) -> Result<(), String> {
@@ -2266,7 +2266,7 @@ impl RemoteControlController {
host.apply_prompt(command).await
}
pub(crate) async fn interrupt_runtime_chat(
pub async fn interrupt_runtime_chat(
&self,
run_id: &str,
scope: &RuntimeChatControlScope,
@@ -3214,7 +3214,7 @@ fn unicode_prefix(value: &str, chars: usize) -> String {
value.chars().take(chars).collect()
}
pub(crate) fn projected_approval_id(raw: &str) -> String {
pub fn projected_approval_id(raw: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(b"local-runtime:approval\0");
hasher.update(raw.as_bytes());
@@ -4439,9 +4439,12 @@ fn parse_remote_command(value: &Value, expected_run_id: &str) -> Result<RemoteCo
"input_provenance",
];
if watch_fields.iter().any(|key| value.get(*key).is_some())
|| value.get("metadata").and_then(Value::as_object).is_some_and(|metadata| {
watch_fields.iter().any(|key| metadata.contains_key(*key))
})
|| value
.get("metadata")
.and_then(Value::as_object)
.is_some_and(|metadata| {
watch_fields.iter().any(|key| metadata.contains_key(*key))
})
{
return Err("Automatic watch input is unsupported on this local coordinator; use the attested HTTP owner.".to_string());
}
@@ -5815,19 +5818,21 @@ mod tests {
if !cfg!(debug_assertions) {
return;
}
let _env = crate::test_support::lock_test_env();
let _env = codewhale_engine::test_support::lock_test_env();
let secrets_root = tempfile::tempdir().expect("isolated remote-control secrets");
let _codewhale_home =
crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", secrets_root.path());
codewhale_engine::test_support::EnvVarGuard::set("CODEWHALE_HOME", secrets_root.path());
let server = MockServer::start().await;
let plane = format!("{}/", server.uri().trim_end_matches('/'));
let _control_plane =
crate::test_support::EnvVarGuard::set("CWC_RUNNER_CONTROL_PLANE_BASE", &plane);
let _control_plane = codewhale_engine::test_support::EnvVarGuard::set(
"CWC_RUNNER_CONTROL_PLANE_BASE",
&plane,
);
let base = runner_control_plane_base().expect("loopback control plane");
save_persisted_enrollment(&fixture_enrollment(&base).persisted)
.expect("persist matching enrollment");
let access_token = crate::test_support::future_test_jwt(&"a".repeat(40));
let access_token = codewhale_engine::test_support::future_test_jwt(&"a".repeat(40));
Mock::given(method("POST"))
.and(path("/api/runner/enrollments/token"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
@@ -7047,8 +7052,7 @@ mod tests {
json!({"type":"prompt.request","runId":"run-1","turnId":"turn-1","prompt":"watch"});
value[field] = json!({"version":1});
assert!(parse_remote_command(&value, "run-1").is_err());
let mut nested =
json!({"type":"prompt.request","runId":"run-1","turnId":"turn-1","prompt":"watch","metadata":{}});
let mut nested = json!({"type":"prompt.request","runId":"run-1","turnId":"turn-1","prompt":"watch","metadata":{}});
nested["metadata"][field] = json!({"version":1});
assert!(parse_remote_command(&nested, "run-1").is_err());
}
@@ -7510,38 +7514,6 @@ mod tests {
);
}
#[test]
fn view_gate_matching_never_confuses_two_approval_cards() {
use crate::tui::approval::ApprovalRequest;
use crate::tui::approval::ApprovalView;
use crate::tui::views::ViewStack;
let request = ApprovalRequest::new(
"tool_A",
"edit",
"Edit A",
&serde_json::json!({ "file": "a" }),
"approval_key_A",
);
let card = ApprovalView::new(request);
let gate_a = projected_approval_id("tool_A");
let gate_b = projected_approval_id("tool_B");
let mut stack = ViewStack::new();
stack.push(card);
assert!(
!stack.remove_approval_for_gate(&gate_b),
"a different gate must NEVER match this card — the whole point of identity-aware dismissal"
);
assert!(!stack.remove_approval_for_gate("local_approval_missing"));
assert!(!stack.is_empty());
assert!(
stack.remove_approval_for_gate(&gate_a),
"the matching gate must match"
);
assert!(stack.is_empty());
}
#[tokio::test]
async fn enrollment_rejection_carries_a_sanitized_actionable_reason() {
let server = MockServer::start().await;
@@ -8028,10 +8000,10 @@ mod tests {
#[tokio::test]
async fn stop_drain_stops_when_a_fresh_credential_is_refused_again() {
let _env = crate::test_support::lock_test_env();
let _env = codewhale_engine::test_support::lock_test_env();
let secrets_root = tempfile::tempdir().expect("isolated remote-control secrets");
let _codewhale_home =
crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", secrets_root.path());
codewhale_engine::test_support::EnvVarGuard::set("CODEWHALE_HOME", secrets_root.path());
let server = MockServer::start().await;
// A terminal refusal: the runner may not write this run, and a new
// access token does not change that.
@@ -8043,7 +8015,7 @@ mod tests {
.expect(2)
.mount(&server)
.await;
let access_token = crate::test_support::future_test_jwt(&"a".repeat(40));
let access_token = codewhale_engine::test_support::future_test_jwt(&"a".repeat(40));
Mock::given(method("POST"))
.and(path("/api/runner/enrollments/token"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
File renamed without changes.
@@ -447,12 +447,12 @@ mod tests {
use std::collections::HashSet;
use std::path::{Path, PathBuf};
/// Repo root, resolved from this crate's manifest dir (`crates/tui`).
/// Repo root, resolved from this crate's manifest dir (`crates/app-server`).
fn repo_root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.and_then(Path::parent)
.expect("crates/tui has a two-level parent (repo root)")
.expect("crates/app-server has a two-level parent (repo root)")
.to_path_buf()
}
File diff suppressed because it is too large. Load diff
@@ -70,7 +70,11 @@ pub(super) async fn require_host_token(
req: Request,
next: Next,
) -> Response {
if state.runtime_token.as_deref().is_some_and(|master| request_has_header_runtime_token(&req, master)) {
if state
.runtime_token
.as_deref()
.is_some_and(|master| request_has_header_runtime_token(&req, master))
{
next.run(req).await
} else {
runtime_token_required_response()
@@ -24,11 +24,11 @@ pub(super) async fn get_constitution(
))
}
None => {
#[cfg(test)]
let env_ticket = crate::test_support::env_scope_ticket();
#[cfg(any(test, feature = "test-support"))]
let env_ticket = codewhale_engine::test_support::env_scope_ticket();
let rendered = tokio::task::spawn_blocking(move || {
#[cfg(test)]
let _membership = crate::test_support::join_env_scope(env_ticket);
#[cfg(any(test, feature = "test-support"))]
let _membership = codewhale_engine::test_support::join_env_scope(env_ticket);
codewhale_engine::prompts::load_active_user_constitution()
.map(|document| document.render_body())
})
@@ -406,7 +406,7 @@ mod tests {
#[test]
fn explicit_profile_never_lists_ambient_crashes() {
use crate::test_support::{EnvVarGuard, lock_test_env};
use codewhale_engine::test_support::{EnvVarGuard, lock_test_env};
let _lock = lock_test_env();
let tmp = tempfile::tempdir().expect("temp");
let _home = EnvVarGuard::set("HOME", tmp.path());
@@ -424,8 +424,9 @@ mod tests {
#[test]
fn invalid_profile_does_not_fall_back_to_ambient_crashes() {
let _lock = crate::test_support::lock_test_env();
let _profile = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", "relative-profile");
let _lock = codewhale_engine::test_support::lock_test_env();
let _profile =
codewhale_engine::test_support::EnvVarGuard::set("CODEWHALE_HOME", "relative-profile");
assert!(crash_dirs().is_empty());
}
File renamed without changes.
File renamed without changes.
@@ -140,3 +140,11 @@ pub(super) fn take(host: &str, port: u16, insecure_no_auth: bool) -> Result<Opti
listener.set_nonblocking(true)?;
Ok(Some(TcpListener::from_std(listener)?))
}
pub fn prepare_runtime_listener_handoff() -> Result<bool> {
requested()
}
pub fn discard_runtime_listener_handoff() {
discard();
}
File renamed without changes.
@@ -27,11 +27,11 @@ fn import_error(error: anyhow::Error) -> ApiError {
pub(super) async fn preview(
State(state): State<RuntimeApiState>,
) -> Result<Json<ImportPreview>, ApiError> {
#[cfg(test)]
let env_ticket = crate::test_support::env_scope_ticket();
#[cfg(any(test, feature = "test-support"))]
let env_ticket = codewhale_engine::test_support::env_scope_ticket();
tokio::task::spawn_blocking(move || {
#[cfg(test)]
let _membership = crate::test_support::join_env_scope(env_ticket);
#[cfg(any(test, feature = "test-support"))]
let _membership = codewhale_engine::test_support::join_env_scope(env_ticket);
let path = state.config.read().mcp_config_path();
let plugins = state
.plugin_discovery
@@ -74,11 +74,11 @@ pub(super) async fn apply(
) -> Result<Json<ImportReceipt>, ApiError> {
let expected = mcp_expected_revision(&headers)?;
request.validate()?;
#[cfg(test)]
let env_ticket = crate::test_support::env_scope_ticket();
#[cfg(any(test, feature = "test-support"))]
let env_ticket = codewhale_engine::test_support::env_scope_ticket();
tokio::task::spawn_blocking(move || {
#[cfg(test)]
let _membership = crate::test_support::join_env_scope(env_ticket);
#[cfg(any(test, feature = "test-support"))]
let _membership = codewhale_engine::test_support::join_env_scope(env_ticket);
let path = state.config.read().mcp_config_path();
let plugins = state
.plugin_discovery
@@ -0,0 +1,188 @@
use super::*;
use crate::{RuntimeOwnerFailure, RuntimeOwnerRequest};
async fn validate(state: &RuntimeApiState) -> Result<()> {
anyhow::ensure!(
!state.runtime_threads.is_acp_host(),
"immutable ACP-base owner cannot admit ordinary native services"
);
state
.workspace_scope
.validate()
.await
.map_err(|_| RuntimeOwnerFailure {
status: 409,
message: "selected workspace identity changed".to_owned(),
code: None,
})?;
anyhow::ensure!(
!state.shutdown.requested.is_cancelled(),
"captured Runtime is stopping"
);
Ok(())
}
fn api<T>(result: std::result::Result<T, ApiError>) -> Result<T> {
result.map_err(|error| {
RuntimeOwnerFailure {
status: error.status.as_u16(),
message: error.message,
code: error.code.map(str::to_owned),
}
.into()
})
}
fn decode<T: serde::de::DeserializeOwned>(body: Value) -> Result<T> {
serde_json::from_value(body).map_err(|error| {
RuntimeOwnerFailure {
status: 400,
message: error.to_string(),
code: None,
}
.into()
})
}
pub(super) async fn execute(state: RuntimeApiState, request: RuntimeOwnerRequest) -> Result<Value> {
validate(&state).await?;
Ok(match request {
RuntimeOwnerRequest::Capabilities => {
json!({ "capabilities": default_runtime_capabilities() })
}
RuntimeOwnerRequest::Providers => {
let Json(value) = api(list_providers(State(state)).await)?;
serde_json::to_value(value)?
}
RuntimeOwnerRequest::ProviderModels {
provider,
provider_id,
cursor,
} => {
let Json(value) = api(list_provider_models(
State(state),
Path(provider),
Query(ListProviderModelsParams {
model_provider_id: provider_id,
cursor,
filter: None,
limit: Some(250),
}),
)
.await)?;
serde_json::to_value(value)?
}
RuntimeOwnerRequest::ReloadConfig => {
let Json(value) = api(reload_config(State(state)).await)?;
serde_json::to_value(value)?
}
RuntimeOwnerRequest::CreateThread(body) => {
let (_, Json(value)) = api(create_thread(State(state), Json(decode(body)?)).await)?;
serde_json::to_value(value)?
}
RuntimeOwnerRequest::ListThreads {
include_archived,
limit,
} => {
let Json(value) = api(list_threads(
State(state),
Query(ThreadsQuery {
limit: Some(limit),
include_archived: Some(include_archived),
archived_only: None,
}),
)
.await)?;
serde_json::to_value(value)?
}
RuntimeOwnerRequest::LoadedThreads => {
serde_json::to_value(state.runtime_threads.loaded_threads().await?)?
}
RuntimeOwnerRequest::RunningThreads => {
let Json(value) = api(list_running_threads(State(state)).await)?;
serde_json::to_value(value)?
}
RuntimeOwnerRequest::GetThread { thread_id } => {
let Json(value) = api(get_thread(State(state), Path(thread_id)).await)?;
serde_json::to_value(value)?
}
RuntimeOwnerRequest::UpdateThread { thread_id, body } => {
let Json(value) =
api(update_thread(State(state), Path(thread_id), Json(decode(body)?)).await)?;
serde_json::to_value(value)?
}
RuntimeOwnerRequest::GetGoal { thread_id } => {
let Json(value) = api(get_thread_goal(State(state), Path(thread_id)).await)?;
serde_json::to_value(value)?
}
RuntimeOwnerRequest::ClearGoal { thread_id } => {
api(delete_thread_goal(State(state), Path(thread_id)).await)?;
Value::Null
}
RuntimeOwnerRequest::HistorySnapshot { thread_id } => {
let Json(value) =
api(thread_history::snapshot_thread_history(State(state), Path(thread_id)).await)?;
serde_json::to_value(value)?
}
RuntimeOwnerRequest::HistoryImport(body) => {
let Json(value) =
api(thread_history::import_thread_history(State(state), Json(body)).await)?;
serde_json::to_value(value)?
}
RuntimeOwnerRequest::HistoryMutate(body) => {
let Json(value) =
api(thread_history::mutate_thread_history(State(state), Json(body)).await)?;
serde_json::to_value(value)?
}
RuntimeOwnerRequest::HistoryLookup(body) => {
let Json(value) =
api(
thread_history::lookup_thread_history_operation(State(state), Json(body)).await,
)?;
serde_json::to_value(value)?
}
RuntimeOwnerRequest::HistoryRecover(body) => {
let Json(value) = api(thread_history::recover_thread_history_operation(
State(state),
Json(body),
)
.await)?;
serde_json::to_value(value)?
}
RuntimeOwnerRequest::StartTurn { thread_id, body } => {
let (_, Json(value)) =
api(start_thread_turn(State(state), Path(thread_id), Json(decode(body)?)).await)?;
serde_json::to_value(value)?
}
RuntimeOwnerRequest::DecideApproval {
approval_id,
decision,
} => {
let value = api(settle_approval_request(&state, approval_id, decision).await)?;
serde_json::to_value(value)?
}
RuntimeOwnerRequest::InterruptTurn { thread_id, turn_id } => {
let Json(value) =
api(interrupt_thread_turn(State(state), Path((thread_id, turn_id))).await)?;
serde_json::to_value(value)?
}
})
}
pub(super) async fn events(
state: RuntimeApiState,
thread_id: String,
since_seq: u64,
) -> Result<crate::RuntimeOwnerEventStream> {
validate(&state).await?;
api(open_thread_event_frames(
state,
thread_id,
ThreadEventsQuery {
since_seq: Some(since_seq),
replay_limit: None,
progress: false,
},
)
.await)
}
@@ -306,11 +306,11 @@ pub(super) async fn set_provider_key(
Path(id): Path<String>,
Json(request): Json<SetProviderKeyRequest>,
) -> Result<Json<Value>, ApiError> {
#[cfg(test)]
let env_ticket = crate::test_support::env_scope_ticket();
#[cfg(any(test, feature = "test-support"))]
let env_ticket = codewhale_engine::test_support::env_scope_ticket();
tokio::task::spawn_blocking(move || {
#[cfg(test)]
let _membership = crate::test_support::join_env_scope(env_ticket);
#[cfg(any(test, feature = "test-support"))]
let _membership = codewhale_engine::test_support::join_env_scope(env_ticket);
let _credential_change = state.provider_switches.blocking_lock();
if state.config.read().session_provider_keys_only
&& request.scope != ProviderKeyScope::Session
@@ -910,11 +910,11 @@ pub(super) async fn clear_provider_key(
State(state): State<RuntimeApiState>,
Path(id): Path<String>,
) -> Result<Json<Value>, ApiError> {
#[cfg(test)]
let env_ticket = crate::test_support::env_scope_ticket();
#[cfg(any(test, feature = "test-support"))]
let env_ticket = codewhale_engine::test_support::env_scope_ticket();
tokio::task::spawn_blocking(move || {
#[cfg(test)]
let _membership = crate::test_support::join_env_scope(env_ticket);
#[cfg(any(test, feature = "test-support"))]
let _membership = codewhale_engine::test_support::join_env_scope(env_ticket);
let _credential_change = state.provider_switches.blocking_lock();
let (identity, scope) = {
let config = state.config.write();
@@ -1059,19 +1059,20 @@ mod tests {
#[test]
fn account_overlay_resolves_without_disk_residue_and_logout_revokes_clones() {
let _env = crate::test_support::lock_test_env();
let _env = codewhale_engine::test_support::lock_test_env();
let tmp = tempfile::tempdir().unwrap();
let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", tmp.path());
let _backend = crate::test_support::EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
let _base = crate::test_support::EnvVarGuard::set(
let _home = codewhale_engine::test_support::EnvVarGuard::set("CODEWHALE_HOME", tmp.path());
let _backend =
codewhale_engine::test_support::EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
let _base = codewhale_engine::test_support::EnvVarGuard::set(
"CODEWHALE_CLOUD_API_BASE",
codewhale_secrets::account::DEFAULT_ACCOUNT_API_BASE,
);
let _api_base = crate::test_support::EnvVarGuard::set(
let _api_base = codewhale_engine::test_support::EnvVarGuard::set(
"CODEWHALE_API_BASE",
codewhale_config::descriptors::defaults::DEFAULT_CODEWHALE_BASE_URL,
);
let _api_key = crate::test_support::EnvVarGuard::set("CODEWHALE_API_KEY", "");
let _api_key = codewhale_engine::test_support::EnvVarGuard::set("CODEWHALE_API_KEY", "");
let store = saved_account_session("fixture-session");
let config = {
let __cw_config_init_0_0 = Some("codewhale".into());
@@ -1193,19 +1194,20 @@ mod tests {
#[test]
fn account_overlay_refuses_endpoint_credentials_and_stale_session_ownership() {
let _env = crate::test_support::lock_test_env();
let _env = codewhale_engine::test_support::lock_test_env();
let tmp = tempfile::tempdir().unwrap();
let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", tmp.path());
let _backend = crate::test_support::EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
let _base = crate::test_support::EnvVarGuard::set(
let _home = codewhale_engine::test_support::EnvVarGuard::set("CODEWHALE_HOME", tmp.path());
let _backend =
codewhale_engine::test_support::EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
let _base = codewhale_engine::test_support::EnvVarGuard::set(
"CODEWHALE_CLOUD_API_BASE",
codewhale_secrets::account::DEFAULT_ACCOUNT_API_BASE,
);
let _api_base = crate::test_support::EnvVarGuard::set(
let _api_base = codewhale_engine::test_support::EnvVarGuard::set(
"CODEWHALE_API_BASE",
codewhale_config::descriptors::defaults::DEFAULT_CODEWHALE_BASE_URL,
);
let _api_key = crate::test_support::EnvVarGuard::set("CODEWHALE_API_KEY", "");
let _api_key = codewhale_engine::test_support::EnvVarGuard::set("CODEWHALE_API_KEY", "");
saved_account_session("fixture-session");
let mut config = {
let __cw_config_init_1_0 = Some("codewhale".into());
@@ -1269,7 +1271,7 @@ mod tests {
#[test]
fn account_overlay_clear_is_shared_and_preserves_manual_config() {
let _env = crate::test_support::lock_test_env();
let _env = codewhale_engine::test_support::lock_test_env();
let mut config = Config::default();
config
.provider_config_for_mut(&config.test_identity_for_kind(ProviderKind::Codewhale))
@@ -1,10 +1,10 @@
use super::*;
use crate::test_support::{EnvVarGuard, lock_test_env};
use anyhow::{Context, bail};
use codewhale_engine::core::events::{Event as EngineEvent, TurnOutcomeStatus};
use codewhale_engine::core::ops::{Op, TurnSpec};
use codewhale_engine::runtime_threads::RuntimeEventRecord;
use codewhale_engine::runtime_threads::manager::install_mock_engine;
use codewhale_engine::test_support::{EnvVarGuard, lock_test_env};
use codewhale_models::Role;
use codewhale_models::Usage;
use futures_util::StreamExt;
@@ -1121,7 +1121,7 @@ async fn spawn_test_server_with_root_token_mobile_workspace_and_overrides(
// server thread must join that scope: its `Config::load` reads env through
// `with_test_env_lock`, which would otherwise block on the mutex the test
// holds while awaiting this very setup — a deadlock.
let env_ticket = crate::test_support::env_scope_ticket();
let env_ticket = codewhale_engine::test_support::env_scope_ticket();
spawn_product_stack_server(
root,
sessions_dir,
@@ -1185,7 +1185,7 @@ fn spawn_product_stack_server(
mobile_enabled: bool,
workspace: PathBuf,
mut overrides: TestServerOverrides,
env_ticket: Option<crate::test_support::EnvScopeTicket>,
env_ticket: Option<codewhale_engine::test_support::EnvScopeTicket>,
setup_tx: oneshot::Sender<Result<Option<TestServerSetup>>>,
) {
let shutdown = overrides
@@ -1201,7 +1201,7 @@ fn spawn_product_stack_server(
.spawn(move || {
// Adopted for the thread's lifetime; the scope's generation check
// refuses enrollment once the sealing test has ended.
let _membership = crate::test_support::join_env_scope(env_ticket);
let _membership = codewhale_engine::test_support::join_env_scope(env_ticket);
let _catalog = catalog.map(codewhale_engine::command_catalog::CatalogGuard::install);
let _extension_manager =
codewhale_engine::extension_host::TestManagerGuard::install(extension_manager);
@@ -1290,7 +1290,7 @@ async fn build_test_server(
axum::Router,
SocketAddr,
SharedRuntimeThreadManager,
Option<codewhale_app_server::daemon_socket::DaemonSocket>,
Option<crate::daemon_socket::DaemonSocket>,
)>,
> {
let _ = rustls::crypto::ring::default_provider().install_default();
@@ -1440,7 +1440,7 @@ async fn build_test_server(
};
let mut app = build_router(state.clone());
if let Some(compatibility) = compatibility {
app = app.merge(codewhale_app_server::http::runtime_compatibility_router(
app = app.merge(crate::http::runtime_compatibility_router(
compatibility,
&state.cors_origins,
state.runtime_token.clone(),
@@ -4605,7 +4605,13 @@ async fn turn_operation_lookup_is_authenticated_read_only_and_survives_restart()
let mut invalid = watch_request.clone();
invalid["watch_event"]["source"]["slot"] = json!(slot);
assert_eq!(
client.post(&watch_url).bearer_auth(token).json(&invalid).send().await?.status(),
client
.post(&watch_url)
.bearer_auth(token)
.json(&invalid)
.send()
.await?
.status(),
StatusCode::BAD_REQUEST
);
}
@@ -4625,7 +4631,13 @@ async fn turn_operation_lookup_is_authenticated_read_only_and_survives_restart()
] {
repository["watch_event"]["source"]["repo"] = json!(repo);
assert_eq!(
client.post(&watch_url).bearer_auth(token).json(&repository).send().await?.status(),
client
.post(&watch_url)
.bearer_auth(token)
.json(&repository)
.send()
.await?
.status(),
StatusCode::BAD_REQUEST
);
}
@@ -4825,8 +4837,16 @@ async fn turn_operation_lookup_is_authenticated_read_only_and_survives_restart()
);
for (harness, engine_turn_id, content) in [
(&engine, "lookup-fixture-engine-turn", "lookup fixture completed"),
(&watch_engine, "watch-fixture-engine-turn", "watch fixture completed"),
(
&engine,
"lookup-fixture-engine-turn",
"lookup fixture completed",
),
(
&watch_engine,
"watch-fixture-engine-turn",
"watch fixture completed",
),
] {
for event in [
EngineEvent::TurnStarted {
@@ -5266,6 +5286,7 @@ async fn event_handoff_replays_and_dedupes_interaction_prompts_without_a_gap() -
false,
CancellationToken::new(),
)
.map(|frame| Ok::<_, Infallible>(thread_event_as_sse(frame)))
.take(2);
let body =
axum::body::to_bytes(Sse::new(stream).into_response().into_body(), usize::MAX).await?;
@@ -5334,6 +5355,7 @@ async fn pet_stream_progress_drains_queued_answers_before_becoming_live() -> Res
true,
CancellationToken::new(),
)
.map(|frame| Ok::<_, Infallible>(thread_event_as_sse(frame)))
.take(4);
let body = tokio::time::timeout(
ci_scaled(Duration::from_secs(2)),
@@ -5428,15 +5450,18 @@ async fn pet_stream_progress_returns_to_replay_while_recovering_broadcast_lag()
let (backlog_tx, backlog) = mpsc::channel(1);
drop(backlog_tx);
let (tx, live) = tokio::sync::broadcast::channel(1);
let mut stream = Box::pin(replay_live_thread_events(
runtime_threads.clone(),
thread.id.clone(),
initial,
backlog,
live,
true,
CancellationToken::new(),
));
let mut stream = Box::pin(
replay_live_thread_events(
runtime_threads.clone(),
thread.id.clone(),
initial,
backlog,
live,
true,
CancellationToken::new(),
)
.map(|frame| Ok::<_, Infallible>(thread_event_as_sse(frame))),
);
assert_eq!(
sse_frame_payload(stream.next().await.unwrap().unwrap()).await?["state"],
"replaying"
@@ -5581,15 +5606,18 @@ async fn replay_failure_ends_stream_at_last_delivered_seq_and_resumes_from_it()
backlog_tx.send(Err("disk read failed".to_string())).await?;
drop(backlog_tx);
let (_live_tx, live) = tokio::sync::broadcast::channel(4);
let frames = render_thread_stream(replay_live_thread_events(
runtime_threads.clone(),
thread.id.clone(),
initial,
backlog,
live,
false,
CancellationToken::new(),
))
let frames = render_thread_stream(
replay_live_thread_events(
runtime_threads.clone(),
thread.id.clone(),
initial,
backlog,
live,
false,
CancellationToken::new(),
)
.map(|frame| Ok::<_, Infallible>(thread_event_as_sse(frame))),
)
.await?;
assert_eq!(frames.len(), 3, "unexpected frames: {frames:?}");
@@ -5697,15 +5725,18 @@ async fn catch_up_failure_ends_stream_with_catch_up_failed() -> Result<()> {
let (backlog_tx, backlog) = mpsc::channel(1);
drop(backlog_tx);
let (live_tx, live) = tokio::sync::broadcast::channel(1);
let mut stream = Box::pin(replay_live_thread_events(
runtime_threads.clone(),
thread.id.clone(),
initial,
backlog,
live,
false,
CancellationToken::new(),
));
let mut stream = Box::pin(
replay_live_thread_events(
runtime_threads.clone(),
thread.id.clone(),
initial,
backlog,
live,
false,
CancellationToken::new(),
)
.map(|frame| Ok::<_, Infallible>(thread_event_as_sse(frame))),
);
let delivered = runtime_threads
.emit_event_for_test(&thread.id, None, "item.completed", json!({"n": 1}))
.await?;
@@ -5761,15 +5792,18 @@ async fn closed_live_source_still_ends_stream_explicitly() -> Result<()> {
.await?;
live_tx.send(event.clone())?;
drop(live_tx);
let frames = render_thread_stream(replay_live_thread_events(
runtime_threads.clone(),
thread.id.clone(),
initial,
backlog,
live,
false,
CancellationToken::new(),
))
let frames = render_thread_stream(
replay_live_thread_events(
runtime_threads.clone(),
thread.id.clone(),
initial,
backlog,
live,
false,
CancellationToken::new(),
)
.map(|frame| Ok::<_, Infallible>(thread_event_as_sse(frame))),
)
.await?;
assert_eq!(frames.len(), 2, "unexpected frames: {frames:?}");
assert_eq!(frames[0].2["seq"], event.seq);
@@ -7469,19 +7503,21 @@ async fn session_save_without_an_id_keeps_the_conversation_that_owns_the_snapsho
.context("missing thread id")?
.to_string();
// A new conversation's id is generated inside the engine, so only a mock
// can state it. It stands in for the id the real engine stamps on every
// snapshot the conversation takes.
const CONVERSATION_ID: &str = "3f9c7b1e-58b4-4d2a-9f5a-8a1e0c6d2b70";
// A Runtime thread's engine runs under the thread id, so that is the id
// every snapshot of the conversation carries.
let conversation_id = thread_id.clone();
let conversation_id = conversation_id.as_str();
let harness = install_mock_engine(&runtime_threads, &thread_id).await;
let mut rx_op = harness.rx_op;
let snapshot_session_id = conversation_id.to_string();
let snapshot_workspace = root.join("workspace");
tokio::spawn(async move {
while let Some(op) = rx_op.recv().await {
if let Op::GetSessionSnapshot { tx } = op {
let snapshot = codewhale_engine::core::ops::SessionSnapshot {
source: None,
session_id: CONVERSATION_ID.to_string(),
session_id: snapshot_session_id.clone(),
messages: vec![Message {
role: Role::User,
content: vec![ContentBlock::Text {
@@ -7493,7 +7529,7 @@ async fn session_save_without_an_id_keeps_the_conversation_that_owns_the_snapsho
model: "deepseek-v4-pro".to_string(),
model_provider: "deepseek".to_string(),
model_provider_id: None,
workspace: root.clone(),
workspace: snapshot_workspace.clone(),
system_prompt: None,
instruction_assembly: None,
mode: "agent".to_string(),
@@ -7514,7 +7550,7 @@ async fn session_save_without_an_id_keeps_the_conversation_that_owns_the_snapsho
.json()
.await?;
assert_eq!(
saved["session_id"], CONVERSATION_ID,
saved["session_id"], conversation_id,
"a save that names no session must persist the conversation's own id"
);
@@ -7526,7 +7562,7 @@ async fn session_save_without_an_id_keeps_the_conversation_that_owns_the_snapsho
.error_for_status()?
.json()
.await?;
assert_eq!(detail["thread"]["session_id"], CONVERSATION_ID);
assert_eq!(detail["thread"]["session_id"], conversation_id);
// Saving again keeps the one document instead of collecting a new one per
// visit — the drift above was also how one conversation came to hold
@@ -7548,7 +7584,7 @@ async fn session_save_without_an_id_keeps_the_conversation_that_owns_the_snapsho
.error_for_status()?
.json()
.await?;
assert_eq!(resaved["session_id"], CONVERSATION_ID);
assert_eq!(resaved["session_id"], conversation_id);
assert_eq!(
documents()?,
before,
@@ -7556,7 +7592,7 @@ async fn session_save_without_an_id_keeps_the_conversation_that_owns_the_snapsho
);
assert!(
sessions_dir
.join(format!("{CONVERSATION_ID}.json"))
.join(format!("{conversation_id}.json"))
.exists()
);
@@ -10278,6 +10314,28 @@ async fn approvals_endpoint_lists_decided_and_pending_newest_first() -> Result<(
/// instead of counting child spend twice. Coverage rides along (#4318):
/// `coverage_recorded` is set and the CNY counters describe the provider's
/// own CNY rows, so a reload reads the totals as known, not legacy-unknown.
fn frozen_deepseek_first_party_quote(
model: &str,
at: chrono::DateTime<Utc>,
) -> (
String,
codewhale_engine::provider_catalog_live::ProviderLivePricingQuote,
) {
let base_url = ProviderKind::Deepseek.provider().default_base_url();
let quote = codewhale_engine::provider_catalog_live::fresh_dispatch_pricing_quote_at(
ProviderKind::Deepseek,
ProviderKind::Deepseek.as_str(),
model,
base_url,
u64::try_from(at.timestamp()).expect("fixture dispatch time"),
)
.expect("fixture frozen first-party quote");
(
codewhale_config::catalog::base_url_fingerprint(base_url),
quote,
)
}
#[tokio::test]
async fn session_save_merges_thread_cost_split_and_records_coverage() -> Result<()> {
let root = std::env::temp_dir().join(format!("deepseek-session-cost-{}", Uuid::new_v4()));
@@ -10309,6 +10367,8 @@ async fn session_save_merges_thread_cost_split_and_records_coverage() -> Result<
// large routed-child (sub-agent) call, both priced.
let store = runtime_threads.test_store();
let now = Utc::now();
let (route_fingerprint, route_quote) =
frozen_deepseek_first_party_quote("deepseek-v4-flash", now);
let mut turn = TurnRecord {
source_usage_origin_turn_id: None,
@@ -10356,8 +10416,8 @@ async fn session_save_merges_thread_cost_split_and_records_coverage() -> Result<
billing_surface: Some(
codewhale_engine::pricing::FIRST_PARTY_PAYG_BILLING_SURFACE.to_string(),
),
endpoint_fingerprint: None,
provider_live_pricing: None,
endpoint_fingerprint: Some(route_fingerprint.clone()),
provider_live_pricing: Some(route_quote.clone()),
billing_mode: codewhale_engine::cost_status::RouteBillingMode::Metered,
dispatched_at: now,
},
@@ -10385,7 +10445,8 @@ async fn session_save_merges_thread_cost_split_and_records_coverage() -> Result<
turn.effective_provider_id = Some(ProviderKind::Deepseek.as_str().to_string());
turn.effective_billing_surface =
Some(codewhale_engine::pricing::FIRST_PARTY_PAYG_BILLING_SURFACE.to_string());
turn.effective_endpoint_fingerprint = None;
turn.effective_endpoint_fingerprint = Some(route_fingerprint);
turn.effective_provider_live_pricing = Some(route_quote);
turn.effective_billing_mode = Some(codewhale_engine::cost_status::RouteBillingMode::Metered);
turn.effective_dispatched_at = Some(now);
turn.effective_model = Some("deepseek-v4-flash".to_string());
@@ -10452,9 +10513,9 @@ async fn session_save_merges_thread_cost_split_and_records_coverage() -> Result<
// under the provider's own CNY row.
assert!(cost.coverage_recorded);
assert!(!cost.coverage_is_legacy_unknown());
assert_eq!(cost.priced_turns, 1);
assert_eq!(cost.priced_turns, 2);
assert_eq!(cost.unpriced_turns, 0);
assert_eq!(cost.cny_priced_turns, 1);
assert_eq!(cost.cny_priced_turns, 2);
assert_eq!(cost.cny_unpriced_turns, 0);
assert!(cost.unpriced_reasons.is_empty());
assert!(
@@ -10518,6 +10579,8 @@ async fn session_save_persists_parent_cny_unpriced_reasons_without_double_count(
let store = runtime_threads.test_store();
let now = Utc::now();
let (route_fingerprint, route_quote) =
frozen_deepseek_first_party_quote("deepseek-v4-flash", now);
let mut turn = TurnRecord {
source_usage_origin_turn_id: None,
@@ -10565,8 +10628,8 @@ async fn session_save_persists_parent_cny_unpriced_reasons_without_double_count(
billing_surface: Some(
codewhale_engine::pricing::FIRST_PARTY_PAYG_BILLING_SURFACE.to_string(),
),
endpoint_fingerprint: None,
provider_live_pricing: None,
endpoint_fingerprint: Some(route_fingerprint),
provider_live_pricing: Some(route_quote),
billing_mode: codewhale_engine::cost_status::RouteBillingMode::Metered,
dispatched_at: now,
},
@@ -10622,8 +10685,8 @@ async fn session_save_persists_parent_cny_unpriced_reasons_without_double_count(
"routed DeepSeek child is priced"
);
assert_eq!(
cost.priced_turns, 0,
"parent coverage columns stay parent-only"
cost.priced_turns, 1,
"coverage columns count the priced routed child record once"
);
assert_eq!(cost.unpriced_turns, 1);
assert_eq!(cost.cny_unpriced_turns, 1);
@@ -12327,7 +12390,7 @@ async fn unicode_skill_activation_matches_api_load_and_owned_lifecycle() -> Resu
let _userprofile = EnvVarGuard::set("USERPROFILE", &home);
let _state_home = EnvVarGuard::set("CODEWHALE_HOME", &root);
let _config = EnvVarGuard::set("CODEWHALE_CONFIG_PATH", tmp.path().join("config.toml"));
crate::test_support::trust_workspace(&workspace);
codewhale_engine::test_support::trust_workspace(&workspace);
let (original_dir, _) = create_managed_skill(&workspace, "技能")?;
create_managed_skill(&workspace, "分析")?;
create_managed_skill(&workspace, "skill")?;
@@ -12571,7 +12634,7 @@ fn resolve_skills_scenario() {
let tmp = tempfile::tempdir().expect("tempdir");
let workspace = tmp.path();
let local_skills = workspace.join(".agents").join("skills");
crate::test_support::trust_workspace(workspace);
codewhale_engine::test_support::trust_workspace(workspace);
fs::create_dir_all(&local_skills).expect("create skills dir");
let config = Config::default();
@@ -12585,7 +12648,7 @@ fn resolve_skills_scenario() {
let tmp = tempfile::tempdir().expect("tempdir");
let workspace = tmp.path();
let local_skills = workspace.join("skills");
crate::test_support::trust_workspace(workspace);
codewhale_engine::test_support::trust_workspace(workspace);
fs::create_dir_all(&local_skills).expect("create skills dir");
let config = {
@@ -12610,7 +12673,7 @@ fn resolve_skills_scenario() {
let codewhale_skills = workspace.join(".codewhale").join("skills");
fs::create_dir_all(&agents_skills).expect("create agents skills dir");
fs::create_dir_all(&codewhale_skills).expect("create codewhale skills dir");
crate::test_support::trust_workspace(workspace);
codewhale_engine::test_support::trust_workspace(workspace);
let config = {
let __cw_config_init_9_0 = Some(codewhale_runtime::config::SkillsConfig {
@@ -12742,7 +12805,7 @@ fn skill_entry_is_bundled_requires_configured_bundle_path() {
#[test]
fn resolve_skills_dir_rejects_symlink_escaping_workspace() {
let tmp = tempfile::tempdir().expect("tempdir");
let _home = crate::test_support::SealedHome::at(tmp.path());
let _home = codewhale_engine::test_support::SealedHome::at(tmp.path());
let workspace_root = tmp.path().join("workspace");
let escape_target = tmp.path().join("escape_target");
fs::create_dir_all(&workspace_root).expect("create workspace");
@@ -12773,7 +12836,7 @@ fn resolve_skills_dir_rejects_symlink_escaping_workspace() {
#[test]
fn resolve_skills_dir_ignores_untrusted_workspace_skills() {
let tmp = tempfile::tempdir().expect("tempdir");
let _home = crate::test_support::SealedHome::at(tmp.path());
let _home = codewhale_engine::test_support::SealedHome::at(tmp.path());
let codewhale_only = {
let __cw_config_init_11_0 = Some(codewhale_runtime::config::SkillsConfig {
scan_codewhale_only: Some(true),
@@ -12808,7 +12871,7 @@ fn resolve_skills_dir_ignores_untrusted_workspace_skills() {
"untrusted {relative} must not be resolved as the skills dir"
);
crate::test_support::trust_workspace(&workspace);
codewhale_engine::test_support::trust_workspace(&workspace);
assert_eq!(
resolve_skills_dir(&config, &workspace),
fs::canonicalize(&local_skills).expect("canonical skills"),
@@ -12821,7 +12884,7 @@ fn resolve_skills_dir_ignores_untrusted_workspace_skills() {
#[test]
fn resolve_skills_dir_rejects_codewhale_only_symlink_escaping_workspace() {
let tmp = tempfile::tempdir().expect("tempdir");
let _home = crate::test_support::SealedHome::at(tmp.path());
let _home = codewhale_engine::test_support::SealedHome::at(tmp.path());
let workspace_root = tmp.path().join("workspace");
let escape_target = tmp.path().join("escape_target");
fs::create_dir_all(&workspace_root).expect("create workspace");
@@ -14017,14 +14080,15 @@ async fn provider_models_expose_exact_image_input_facts_and_thread_selection_sta
#[test]
fn provider_reasoning_metadata_keeps_exact_roster_levels_and_unknown_boundaries() {
let _lock = crate::test_support::lock_test_env();
let _lock = codewhale_engine::test_support::lock_test_env();
let root = tempfile::tempdir().unwrap();
let canonical_home = root
.path()
.canonicalize()
.expect("canonical private fixture home");
let _codex_home = crate::test_support::EnvVarGuard::set("CODEX_HOME", &canonical_home);
let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &canonical_home);
let _codex_home =
codewhale_engine::test_support::EnvVarGuard::set("CODEX_HOME", &canonical_home);
let _home = codewhale_engine::test_support::EnvVarGuard::set("CODEWHALE_HOME", &canonical_home);
let mut config = {
let __cw_config_init_14_0 = Some("openai-codex".to_string());
let mut __cw_config_init_14_value: Config = Config::default();
@@ -14098,8 +14162,9 @@ fn provider_reasoning_metadata_keeps_exact_roster_levels_and_unknown_boundaries(
assert_eq!(astra.reasoning_effort_source, Some("chatgpt_plan_api"));
// The relay preserves the same model facts without elevating them into
// a tool-execution or account-entitlement receipt.
let _token = crate::test_support::EnvVarGuard::set("OPENAI_CODEX_ACCESS_TOKEN", "test-token");
let _legacy = crate::test_support::EnvVarGuard::remove("CODEX_ACCESS_TOKEN");
let _token =
codewhale_engine::test_support::EnvVarGuard::set("OPENAI_CODEX_ACCESS_TOKEN", "test-token");
let _legacy = codewhale_engine::test_support::EnvVarGuard::remove("CODEX_ACCESS_TOKEN");
let relay = runtime_chat_relay_catalog(&config, &"c".repeat(32)).unwrap();
let relayed_astra = relay["providers"][0]["models"]
.as_array()
@@ -17978,7 +18043,7 @@ async fn skill_detail_returns_body_and_routing_metadata() -> Result<()> {
let root = tmp.path().join("runtime");
let workspace = tmp.path().to_path_buf();
let _config = EnvVarGuard::set("CODEWHALE_CONFIG_PATH", tmp.path().join("config.toml"));
crate::test_support::trust_workspace(&workspace);
codewhale_engine::test_support::trust_workspace(&workspace);
let sessions_dir = root.join("sessions");
fs::create_dir_all(&root)?;
@@ -18072,7 +18137,7 @@ async fn skill_list_rows_carry_routing_metadata() -> Result<()> {
let root = tmp.path().join("runtime");
let workspace = tmp.path().to_path_buf();
let _config = EnvVarGuard::set("CODEWHALE_CONFIG_PATH", tmp.path().join("config.toml"));
crate::test_support::trust_workspace(&workspace);
codewhale_engine::test_support::trust_workspace(&workspace);
let sessions_dir = root.join("sessions");
fs::create_dir_all(&root)?;
@@ -18126,7 +18191,7 @@ async fn listed_tier_belongs_to_the_bundled_copy_only() -> Result<()> {
let root = tmp.path().join("runtime");
let workspace = tmp.path().to_path_buf();
let _config = EnvVarGuard::set("CODEWHALE_CONFIG_PATH", tmp.path().join("config.toml"));
crate::test_support::trust_workspace(&workspace);
codewhale_engine::test_support::trust_workspace(&workspace);
let sessions_dir = root.join("sessions");
fs::create_dir_all(&root)?;
@@ -18219,7 +18284,7 @@ async fn skill_lifecycle_uninstall_removes_installed_skill() -> Result<()> {
// Project skills load only in a trusted workspace; a sealed config path
// lets the server's threads read the same trust record.
let _config = EnvVarGuard::set("CODEWHALE_CONFIG_PATH", tmp.path().join("config.toml"));
crate::test_support::trust_workspace(&workspace);
codewhale_engine::test_support::trust_workspace(&workspace);
let sessions_dir = root.join("sessions");
fs::create_dir_all(&root)?;
@@ -19106,7 +19171,7 @@ async fn plugin_api_404s_for_unknown_selector() -> Result<()> {
#[tokio::test]
async fn dsh_package_preview_then_exact_install_over_http() -> Result<()> {
let _home = crate::test_support::SealedHome::new();
let _home = codewhale_engine::test_support::SealedHome::new();
let tmp = tempfile::tempdir()?;
let root = tmp.path().join("runtime");
let workspace = tmp.path().join("ws");
@@ -19538,7 +19603,7 @@ async fn stream_compat_mapping_forwards_runtime_store_failures() -> Result<()> {
#[tokio::test]
async fn reload_config_updates_openrouter_vendor_for_new_clients_only() -> Result<()> {
let _env = crate::test_support::lock_test_env();
let _env = codewhale_engine::test_support::lock_test_env();
let temp = tempfile::tempdir()?;
let config_file = temp.path().join("vendor-pin.toml");
fs::write(&config_file, "# initial\n")?;
@@ -19589,10 +19654,10 @@ vendor = "{vendor}"
#[test]
fn oauth_pass_through_provider_lists_catalog_models_without_a_live_listing() {
let _env = crate::test_support::lock_test_env();
let _env = codewhale_engine::test_support::lock_test_env();
let _live = codewhale_engine::provider_lake::lock_live_snapshot();
let home = tempfile::tempdir().unwrap();
let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
let _home = codewhale_engine::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
codewhale_engine::provider_catalog_live::reset_cache_for_test();
codewhale_engine::provider_lake::clear_live_snapshot();
let mut config = {
@@ -19631,10 +19696,10 @@ fn api_provider_default_and_model_list_follow_exact_local_catalog() {
base_url_fingerprint, now_unix,
};
let _env = crate::test_support::lock_test_env();
let _env = codewhale_engine::test_support::lock_test_env();
let _live = codewhale_engine::provider_lake::lock_live_snapshot();
let home = tempfile::tempdir().unwrap();
let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
let _home = codewhale_engine::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
codewhale_engine::provider_catalog_live::reset_cache_for_test();
codewhale_engine::provider_lake::clear_live_snapshot();
let provider = ProviderKind::Ollama;
@@ -20232,10 +20297,15 @@ async fn native_notification_replay_rechecks_requests_settled_during_the_read()
.context("replay hook closed")?;
assert_eq!(point.thread_id, thread.id);
if let Some((approval_id, approval)) = approval {
assert!(manager.deliver_external_approval(
&approval_id,
ExternalApprovalDecision::Allow { remember: false },
));
assert!(
manager
.deliver_external_approval(
&approval_id,
ExternalApprovalDecision::Allow { remember: false },
)
.await
.expect("durable approval decision")
);
assert_eq!(
approval.await?,
ExternalApprovalDecision::Allow { remember: false }
@@ -20287,9 +20357,9 @@ async fn runtime_image_http_rejects_before_dispatch_and_accepts_large_canonical_
{
use base64::Engine as _;
use codewhale_engine::image_attach::test_support::runtime_image_fixture;
let _env = crate::test_support::lock_test_env();
let _env = codewhale_engine::test_support::lock_test_env();
let dir = tempfile::tempdir()?;
let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", dir.path());
let _home = codewhale_engine::test_support::EnvVarGuard::set("CODEWHALE_HOME", dir.path());
let mut config = {
let __cw_config_init_19_0 = Some("deepseek".into());
let __cw_config_init_19_1 = Some("deepseek-v4-flash-vision-exp".into());
@@ -20419,9 +20489,9 @@ async fn runtime_image_stream_rejection_does_not_leave_empty_threads() -> Result
use codewhale_engine::image_attach::test_support::{
runtime_image_fixture, runtime_image_fixture_bytes,
};
let _env = crate::test_support::lock_test_env();
let _env = codewhale_engine::test_support::lock_test_env();
let dir = tempfile::tempdir()?;
let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", dir.path());
let _home = codewhale_engine::test_support::EnvVarGuard::set("CODEWHALE_HOME", dir.path());
let mut config = {
let __cw_config_init_20_0 = Some("deepseek".into());
let __cw_config_init_20_1 = Some("deepseek-v4-flash-vision-exp".into());
@@ -20487,9 +20557,9 @@ async fn runtime_image_stream_rejection_does_not_leave_empty_threads() -> Result
#[tokio::test]
async fn runtime_image_named_catalog_resolves_and_echoes_exact_configured_identity() -> Result<()> {
let _env = crate::test_support::lock_test_env();
let _env = codewhale_engine::test_support::lock_test_env();
let dir = tempfile::tempdir()?;
let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", dir.path());
let _home = codewhale_engine::test_support::EnvVarGuard::set("CODEWHALE_HOME", dir.path());
let route = codewhale_runtime::config::ProviderConfig {
kind: Some("openai-compatible".into()),
base_url: Some("http://127.0.0.1:9/v1".into()),
@@ -20617,9 +20687,9 @@ fn runtime_image_named_catalog_cursor_binds_identity_endpoint_and_catalog() -> R
#[tokio::test]
async fn runtime_image_saved_session_import_validates_before_creating_a_thread() -> Result<()> {
use codewhale_models::{ContentBlock, ImageUrlContent};
let _env = crate::test_support::lock_test_env();
let _env = codewhale_engine::test_support::lock_test_env();
let dir = tempfile::tempdir()?;
let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", dir.path());
let _home = codewhale_engine::test_support::EnvVarGuard::set("CODEWHALE_HOME", dir.path());
let sessions_dir = dir.path().join("sessions");
fs::create_dir_all(&sessions_dir)?;
let (addr, manager, server) =
@@ -20715,7 +20785,7 @@ async fn runtime_image_saved_session_import_validates_before_creating_a_thread()
#[test]
fn output_cap_catalog_reports_exact_transport_support() {
use codewhale_config::route::CapabilityState;
let _env = crate::test_support::lock_test_env();
let _env = codewhale_engine::test_support::lock_test_env();
let config = Config::default();
assert_eq!(
provider_model_output_token_limit_for_api(
@@ -22427,17 +22497,21 @@ async fn thread_context_reports_live_budget_from_the_engine() -> Result<()> {
async fn provider_key_write_is_write_only_and_reports_readiness() -> Result<()> {
// The credential store is only exposed to tests under an explicit isolated
// home plus an explicit backend — both scoped to this test by the env lock.
let _env_lock = crate::test_support::lock_test_env();
let _env_lock = codewhale_engine::test_support::lock_test_env();
let tmp = tempfile::tempdir()?;
let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", tmp.path().join("cwhome"));
let _backend = crate::test_support::EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
let _cli_key = crate::test_support::EnvVarGuard::remove("CODEWHALE_CLI_API_KEY");
let _home = codewhale_engine::test_support::EnvVarGuard::set(
"CODEWHALE_HOME",
tmp.path().join("cwhome"),
);
let _backend =
codewhale_engine::test_support::EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
let _cli_key = codewhale_engine::test_support::EnvVarGuard::remove("CODEWHALE_CLI_API_KEY");
let _provider_keys: Vec<_> = ProviderKind::Deepseek
.provider()
.env_vars()
.iter()
.copied()
.map(crate::test_support::EnvVarGuard::remove)
.map(codewhale_engine::test_support::EnvVarGuard::remove)
.collect();
fs::create_dir_all(tmp.path().join("cwhome"))?;
let workspace = tmp.path().join("workspace");
@@ -22668,12 +22742,16 @@ async fn provider_key_write_is_write_only_and_reports_readiness() -> Result<()>
#[tokio::test]
async fn diagnostics_list_and_read_bounded_windows() -> Result<()> {
// Log/crash directories resolve from the codewhale home — isolate it.
let _env_lock = crate::test_support::lock_test_env();
let _env_lock = codewhale_engine::test_support::lock_test_env();
let tmp = tempfile::tempdir()?;
let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", tmp.path().join("cwhome"));
let _home = codewhale_engine::test_support::EnvVarGuard::set(
"CODEWHALE_HOME",
tmp.path().join("cwhome"),
);
// An explicit profile owns both logs and crashes, even when ambient
// legacy crash files exist outside it.
let _user_home = crate::test_support::EnvVarGuard::set("HOME", tmp.path().join("userhome"));
let _user_home =
codewhale_engine::test_support::EnvVarGuard::set("HOME", tmp.path().join("userhome"));
let logs = tmp.path().join("cwhome").join("logs");
let crashes = tmp.path().join("cwhome").join("crashes");
let ambient_crashes = tmp
@@ -23802,10 +23880,14 @@ async fn git_write_preconditions_reject_stale_state() -> Result<()> {
/// for a credential Codewhale does not own.
#[tokio::test]
async fn provider_key_clear_round_trips_and_reports_writability() -> Result<()> {
let _env_lock = crate::test_support::lock_test_env();
let _env_lock = codewhale_engine::test_support::lock_test_env();
let tmp = tempfile::tempdir()?;
let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", tmp.path().join("cwhome"));
let _backend = crate::test_support::EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
let _home = codewhale_engine::test_support::EnvVarGuard::set(
"CODEWHALE_HOME",
tmp.path().join("cwhome"),
);
let _backend =
codewhale_engine::test_support::EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
fs::create_dir_all(tmp.path().join("cwhome"))?;
let workspace = tmp.path().join("workspace");
fs::create_dir_all(&workspace)?;
@@ -24292,10 +24374,11 @@ async fn thread_notices_serve_list_and_ack() -> Result<()> {
#[tokio::test]
async fn provider_models_refresh_uses_exact_configured_route_without_switching() -> Result<()> {
let _env = crate::test_support::lock_test_env();
let _env = codewhale_engine::test_support::lock_test_env();
let root = tempfile::tempdir()?;
let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", root.path());
let _cli = crate::test_support::EnvVarGuard::remove(codewhale_config::CLI_API_KEY_ENV);
let _home = codewhale_engine::test_support::EnvVarGuard::set("CODEWHALE_HOME", root.path());
let _cli =
codewhale_engine::test_support::EnvVarGuard::remove(codewhale_config::CLI_API_KEY_ENV);
codewhale_engine::provider_catalog_live::reset_cache_for_test();
let upstream = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
@@ -24587,7 +24670,7 @@ async fn mcp_management_selected_connection_and_retry_preserve_siblings() -> Res
#[tokio::test]
async fn mcp_management_project_ownership_blocks_global_shadow_mutations() -> Result<()> {
let _env = crate::test_support::lock_test_env();
let _env = codewhale_engine::test_support::lock_test_env();
let root = tempfile::tempdir()?;
let workspace = root.path().join("workspace");
fs::create_dir_all(workspace.join(".codewhale"))?;
@@ -24605,8 +24688,9 @@ async fn mcp_management_project_ownership_blocks_global_shadow_mutations() -> Re
toml::Value::Table(projects),
)])))?,
)?;
let _path = crate::test_support::EnvVarGuard::set("CODEWHALE_CONFIG_PATH", &trust_path);
let _legacy = crate::test_support::EnvVarGuard::remove("DEEPSEEK_CONFIG_PATH");
let _path =
codewhale_engine::test_support::EnvVarGuard::set("CODEWHALE_CONFIG_PATH", &trust_path);
let _legacy = codewhale_engine::test_support::EnvVarGuard::remove("DEEPSEEK_CONFIG_PATH");
let global = json!({"servers":{"shared":{"command":"global-command"},"global":{"command":"global-command"}}}).to_string();
let project = json!({"servers":{"shared":{"command":root.path().join("missing-project-executable")},"project":{"command":root.path().join("missing-project-executable")}}}).to_string();
fs::write(root.path().join("mcp.json"), &global)?;
@@ -26797,7 +26881,7 @@ async fn workspace_trust_route_refuses_malformed_bodies() -> Result<()> {
/// the HTTP read serves.
#[test]
fn workspace_bootstrap_status_reports_the_trust_gate() -> Result<()> {
let _home = crate::test_support::SealedHome::new();
let _home = codewhale_engine::test_support::SealedHome::new();
let tmp = tempfile::tempdir()?;
let workspace = tmp.path().join("project");
fs::create_dir_all(&workspace)?;
@@ -5,7 +5,7 @@
//! bound. What is pinned here is which endpoint this `serve` run picks, and
//! that it picks the same one however the store was spelled.
use super::*;
use crate::test_support::{EnvVarGuard, lock_test_env};
use codewhale_engine::test_support::{EnvVarGuard, lock_test_env};
fn inputs() -> codewhale_paths::daemon_socket::SocketPathInputs {
codewhale_paths::daemon_socket::SocketPathInputs {
@@ -77,10 +77,7 @@ impl Rig {
execution_scope: binding.execution_scope,
lease_generation: generation,
pid: std::process::id(),
process_start: codewhale_app_server::daemon_socket::capture_process_start(
std::process::id(),
)
.await?,
process_start: crate::daemon_socket::capture_process_start(std::process::id()).await?,
principal: PrivateDirectory::current_user_id().to_string(),
socket_path: self.socket.clone(),
config_path: Some(self.config_path.clone()),
@@ -119,8 +116,7 @@ impl Rig {
.spawn()?,
);
owner.pid = child.0.id();
owner.process_start =
codewhale_app_server::daemon_socket::capture_process_start(owner.pid).await?;
owner.process_start = crate::daemon_socket::capture_process_start(owner.pid).await?;
child.0.kill()?;
child.0.wait()?;
self.publish(&owner)?;
@@ -203,11 +199,11 @@ async fn two_crash_reentries_converge_on_one_owner_and_preserve_saved_draft() ->
let owner = rig.receipt(&manager).await?;
assert_ne!(owner.lease_generation, old.lease_generation);
assert_eq!(owner.execution_scope, old.execution_scope);
let (daemon, _) = codewhale_app_server::bind_runtime_frontends(
let (daemon, _) = crate::bind_runtime_frontends(
Some(rig.config_path.clone()),
None,
owner.clone(),
codewhale_app_server::RuntimeOwnerRouting {
crate::RuntimeOwnerRouting {
endpoint: "127.0.0.1:1".parse()?,
workspace: None,
workers: None,
@@ -271,7 +267,7 @@ async fn dead_publication_with_missing_existing_lease_refuses_without_recreation
#[tokio::test]
async fn owner_bootstrap_attach_response_loss_never_falls_back_or_changes_store() -> Result<()> {
use tokio::io::AsyncBufReadExt as _;
use tokio::io::AsyncWriteExt as _;
let _env = lock_test_env();
let rig = Rig::new()?;
let manager = rig.manager(None, None)?;
@@ -287,16 +283,21 @@ async fn owner_bootstrap_attach_response_loss_never_falls_back_or_changes_store(
parent.write_owned_file("daemon.sock.owner.json", &bytes, false)?;
let peer = tokio::spawn(async move {
let (stream, _) = listener.accept().await?;
let mut line = String::new();
tokio::io::BufReader::new(stream)
.read_line(&mut line)
.await?;
let (read, write) = tokio::io::split(stream);
let transport = crate::websocket::accept(read, write).await?;
let (read, mut write) = tokio::io::split(transport);
let mut lines = crate::stdio::BoundedLines::new(tokio::io::BufReader::new(read));
let line = lines
.next_line()
.await?
.context("one complete attach frame")?;
let request: Value = serde_json::from_str(&line)?;
anyhow::ensure!(
request["method"] == "daemon/attach",
"one real attach write"
);
// Simulate lost acknowledgement after the valid peer saw attach.
write.shutdown().await?;
Ok::<_, anyhow::Error>(())
});
let error = rig.admit().await.err().context("lost attach must refuse")?;
@@ -11,10 +11,7 @@ impl Drop for AbortOnDrop {
}
}
async fn reply(
guest: &mut codewhale_app_server::daemon_client::OwnerClient,
id: &str,
) -> Result<Value> {
async fn reply(guest: &mut crate::daemon_client::OwnerClient, id: &str) -> Result<Value> {
tokio::time::timeout(ci_scaled(Duration::from_secs(60)), async {
loop {
let frame = guest.recv().await?.context("owner closed before reply")?;
@@ -68,11 +65,8 @@ async fn authenticated_owner_guest_runs_real_engine_and_reuses_held_store() -> R
let (binding, generation) =
codewhale_runtime::runtime_control::owner_work(move || manager.capture_control_owner())
.await?;
let owner_client = codewhale_app_server::daemon_client::connect(
Some(config_path.clone()),
Some(socket_path.clone()),
)
.await?;
let owner_client =
crate::daemon_client::connect(Some(config_path.clone()), Some(socket_path.clone())).await?;
let captured = owner_client.receipt().clone();
assert_eq!(captured.data_dir, binding.data_dir);
assert_eq!(captured.execution_scope, binding.execution_scope);
@@ -82,7 +76,7 @@ async fn authenticated_owner_guest_runs_real_engine_and_reuses_held_store() -> R
assert_eq!(captured.pid, std::process::id());
assert_eq!(
captured.process_start,
codewhale_app_server::daemon_socket::capture_process_start(std::process::id()).await?
crate::daemon_socket::capture_process_start(std::process::id()).await?
);
#[cfg(unix)]
assert_eq!(
@@ -98,7 +92,7 @@ async fn authenticated_owner_guest_runs_real_engine_and_reuses_held_store() -> R
.await?
);
drop(owner_client);
let acp = codewhale_app_server::daemon_client::connect_acp_if_published(
let acp = crate::daemon_client::connect_acp_if_published(
Some(config_path.clone()),
Some(socket_path.clone()),
)
@@ -124,11 +118,8 @@ async fn authenticated_owner_guest_runs_real_engine_and_reuses_held_store() -> R
assert_eq!(unauthenticated.status(), StatusCode::UNAUTHORIZED);
// Compatibility prompts address an admitted durable thread, never an
// invented alias that could remint an existing conversation.
let mut creator = codewhale_app_server::daemon_client::connect(
Some(config_path.clone()),
Some(socket_path.clone()),
)
.await?;
let mut creator =
crate::daemon_client::connect(Some(config_path.clone()), Some(socket_path.clone())).await?;
assert_eq!(creator.receipt(), &captured);
creator
.send(
@@ -149,11 +140,9 @@ async fn authenticated_owner_guest_runs_real_engine_and_reuses_held_store() -> R
("second", "second owner answer"),
] {
model.push_turn(canned::simple_text_turn(answer));
let mut guest = codewhale_app_server::daemon_client::connect(
Some(config_path.clone()),
Some(socket_path.clone()),
)
.await?;
let mut guest =
crate::daemon_client::connect(Some(config_path.clone()), Some(socket_path.clone()))
.await?;
assert_eq!(guest.receipt(), &captured);
guest
.send(
@@ -209,12 +198,9 @@ async fn authenticated_owner_guest_runs_real_engine_and_reuses_held_store() -> R
assert!(shutdown.drain(Duration::from_secs(5)).await);
tokio::time::timeout(Duration::from_secs(5), async {
loop {
if codewhale_app_server::daemon_client::connect(
Some(config_path.clone()),
Some(socket_path.clone()),
)
.await
.is_err()
if crate::daemon_client::connect(Some(config_path.clone()), Some(socket_path.clone()))
.await
.is_err()
{
break;
}
@@ -263,8 +249,8 @@ fn listener_selection(
workspace: PathBuf,
port: u16,
token: String,
) -> codewhale_app_server::RuntimeListenerSelection {
codewhale_app_server::RuntimeListenerSelection {
) -> crate::RuntimeListenerSelection {
crate::RuntimeListenerSelection {
workers: 1,
workspace,
config_profile: None,
@@ -290,7 +276,7 @@ fn authenticated_http(token: &str) -> Result<reqwest::Client> {
.build()?)
}
async fn frontend_ready(
guest: &mut codewhale_app_server::daemon_client::OwnerClient,
guest: &mut crate::daemon_client::OwnerClient,
) -> Result<RuntimeFrontendReady> {
let frame = tokio::time::timeout(Duration::from_secs(10), guest.recv())
.await??
@@ -348,13 +334,13 @@ async fn real_normal_owner_distinct_listener_detach_keeps_turn_compat_acp_and_su
release: tokio::sync::Semaphore::new(0),
});
manager.set_test_model_client(model.clone());
let control = codewhale_app_server::daemon_client::connect(None, Some(socket.clone())).await?;
let control = crate::daemon_client::connect(None, Some(socket.clone())).await?;
let receipt = control.receipt().clone();
validate_selected_owner(&control, receipt.data_dir.clone()).await?;
drop(control);
let pending_workspace = root.join("pending-workspace");
fs::create_dir(&pending_workspace)?;
let mut guest = codewhale_app_server::daemon_client::connect_listener_if_published(
let mut guest = crate::daemon_client::connect_listener_if_published(
None,
Some(socket.clone()),
listener_selection(pending_workspace.clone(), 0, selected_token.into()),
@@ -437,10 +423,10 @@ async fn real_normal_owner_distinct_listener_detach_keeps_turn_compat_acp_and_su
// Compatibility uses the same captured original bridge and actual Engine.
let selected_workspace = root.join("compat-workspace");
fs::create_dir(&selected_workspace)?;
let mut compat = codewhale_app_server::daemon_client::connect_scoped_control_if_published(
let mut compat = crate::daemon_client::connect_scoped_control_if_published(
None,
Some(socket.clone()),
codewhale_app_server::RuntimeFrontendScope {
crate::RuntimeFrontendScope {
workers: 1,
workspace: selected_workspace.clone(),
config_profile: None,
@@ -474,10 +460,10 @@ async fn real_normal_owner_distinct_listener_detach_keeps_turn_compat_acp_and_su
assert!(result["error"].is_null(), "{result:#}");
assert_eq!(result["result"]["output"], "compat answer");
drop(compat);
let mut acp = codewhale_app_server::daemon_client::connect_selected_acp_if_published(
let mut acp = crate::daemon_client::connect_selected_acp_if_published(
None,
Some(socket.clone()),
codewhale_app_server::RuntimeFrontendScope {
crate::RuntimeFrontendScope {
workers: 1,
workspace: workspace.clone(),
config_profile: None,
@@ -603,12 +589,12 @@ async fn real_owner_exact_listener_reuse_scope_refusal_and_fresh_browser_project
.await?
.context("real listener required")?;
let _abort = AbortOnDrop(server.abort_handle());
let control = codewhale_app_server::daemon_client::connect(None, Some(socket.clone())).await?;
let control = crate::daemon_client::connect(None, Some(socket.clone())).await?;
let receipt = control.receipt().clone();
drop(control);
let same = listener_selection(workspace.clone(), original.port(), token.into());
assert!(!format!("{same:?}").contains(token));
let mut reuse = codewhale_app_server::daemon_client::connect_listener_if_published(
let mut reuse = crate::daemon_client::connect_listener_if_published(
None,
Some(socket.clone()),
same.clone(),
@@ -632,7 +618,7 @@ async fn real_owner_exact_listener_reuse_scope_refusal_and_fresh_browser_project
let mut wrong = same;
wrong.workers = 2;
assert!(
codewhale_app_server::daemon_client::connect_listener_if_published(
crate::daemon_client::connect_listener_if_published(
None,
Some(socket.clone()),
wrong,
@@ -642,10 +628,10 @@ async fn real_owner_exact_listener_reuse_scope_refusal_and_fresh_browser_project
.is_err()
);
assert!(
codewhale_app_server::daemon_client::connect_scoped_control_if_published(
crate::daemon_client::connect_scoped_control_if_published(
None,
Some(socket.clone()),
codewhale_app_server::RuntimeFrontendScope {
crate::RuntimeFrontendScope {
workers: 1,
workspace: workspace.clone(),
config_profile: Some("unadmitted-profile".into()),
@@ -659,7 +645,7 @@ async fn real_owner_exact_listener_reuse_scope_refusal_and_fresh_browser_project
let mut stale = receipt.clone();
stale.lease_generation.push_str("-stale");
assert!(
codewhale_app_server::daemon_client::connect_listener_if_published(
crate::daemon_client::connect_listener_if_published(
None,
Some(socket.clone()),
listener_selection(workspace.clone(), 0, token.into()),
@@ -677,7 +663,7 @@ async fn real_owner_exact_listener_reuse_scope_refusal_and_fresh_browser_project
let mut browser = listener_selection(other_workspace.clone(), port, token.into());
browser.web = true;
browser.mobile = true;
let mut guest = codewhale_app_server::daemon_client::connect_listener_if_published(
let mut guest = crate::daemon_client::connect_listener_if_published(
None,
Some(socket.clone()),
browser,
@@ -803,10 +789,10 @@ async fn real_owner_two_workspace_services_share_scope_caches_and_settle_global_
"workspace service admission must reuse the real owner's worker actor"
);
let control = codewhale_app_server::daemon_client::connect(None, Some(socket.clone())).await?;
let control = crate::daemon_client::connect(None, Some(socket.clone())).await?;
let receipt = control.receipt().clone();
drop(control);
let mut first = codewhale_app_server::daemon_client::connect_listener_if_published(
let mut first = crate::daemon_client::connect_listener_if_published(
None,
Some(socket.clone()),
listener_selection(selected_workspace.clone(), 0, token.into()),
@@ -815,7 +801,7 @@ async fn real_owner_two_workspace_services_share_scope_caches_and_settle_global_
.await?
.context("first selected listener")?;
let first_ready = frontend_ready(&mut first).await?;
let mut second = codewhale_app_server::daemon_client::connect_listener_if_published(
let mut second = crate::daemon_client::connect_listener_if_published(
None,
Some(socket.clone()),
listener_selection(selected_workspace.clone(), 0, token.into()),
@@ -1319,16 +1305,13 @@ async fn real_owner_rejects_different_operator_source_before_attach_and_accepts_
.await?
.context("actual config-bound owner required")?;
let _abort = AbortOnDrop(server.abort_handle());
let control = codewhale_app_server::daemon_client::connect(
Some(config_path.clone()),
Some(socket.clone()),
)
.await?;
let control =
crate::daemon_client::connect(Some(config_path.clone()), Some(socket.clone())).await?;
let receipt = control.receipt().clone();
drop(control);
let mut wrong = listener_selection(workspace.clone(), 0, token.into());
wrong.config_source = Some(other_config.clone());
let error = codewhale_app_server::daemon_client::connect_listener_if_published(
let error = crate::daemon_client::connect_listener_if_published(
Some(config_path.clone()),
Some(socket.clone()),
wrong,
@@ -1346,7 +1329,7 @@ async fn real_owner_rejects_different_operator_source_before_attach_and_accepts_
assert!(!message.contains(other_config.to_str().unwrap()));
let mut matching = listener_selection(workspace, 0, token.into());
matching.config_source = Some(alias);
let mut selected = codewhale_app_server::daemon_client::connect_listener_if_published(
let mut selected = crate::daemon_client::connect_listener_if_published(
Some(config_path),
Some(socket),
matching,
@@ -1520,9 +1503,9 @@ async fn canonical_history_import_runs_real_engine_retains_all_branches_and_refu
);
let directory = sessions_dir.clone();
let session_id = receipt.session_id.clone();
let ticket = crate::test_support::env_scope_ticket();
let ticket = codewhale_engine::test_support::env_scope_ticket();
let saved = codewhale_runtime::runtime_control::owner_work(move || {
let _scope = crate::test_support::join_env_scope(ticket);
let _scope = codewhale_engine::test_support::join_env_scope(ticket);
Ok(SessionManager::new(directory)?.load_session_snapshot(&session_id)?)
})
.await?;
@@ -1668,9 +1651,9 @@ async fn canonical_history_import_runs_real_engine_retains_all_branches_and_refu
assert!(serde_json::to_string(&full.messages)?.contains("canonical successor"));
let directory = sessions_dir;
let session_id = receipt.session_id.clone();
let ticket = crate::test_support::env_scope_ticket();
let ticket = codewhale_engine::test_support::env_scope_ticket();
codewhale_runtime::runtime_control::owner_work(move || {
let _scope = crate::test_support::join_env_scope(ticket);
let _scope = codewhale_engine::test_support::join_env_scope(ticket);
let manager = SessionManager::new(directory.clone())?;
let _lease = manager.reserve_session_for_external_write(&session_id)?;
fs::remove_file(directory.join(format!("{session_id}.json")))?;
@@ -846,7 +846,7 @@ pub(super) async fn mutate_thread_history(
/// The HTTP frontend and the inactive local mounted handoff invoke this same
/// actual held manager. It creates no transport, guest manager or policy owner.
pub(crate) async fn mutate_thread_history_in_runtime(
pub async fn mutate_thread_history_in_runtime(
runtime: &Arc<RuntimeThreadManager>,
sessions_dir: &Path,
config_path: Option<&Path>,
@@ -889,7 +889,7 @@ fn journal_digest(journal: &SessionJournal) -> Result<String> {
))
}
pub(crate) fn session_goal_digest(
pub fn session_goal_digest(
goal: &Option<codewhale_engine::session_manager::SessionGoalState>,
) -> Result<String> {
Ok(codewhale_runtime::hashing::sha256_hex(
@@ -1846,7 +1846,7 @@ pub(super) async fn lookup_thread_history_operation(
/// A read-only exact-key lookup never resumes an Engine, mutates a thread, or
/// guesses absence from a damaged intent/document. It authenticates current
/// binding separately from the historical receipt.
pub(crate) async fn lookup_thread_history_operation_in_runtime(
pub async fn lookup_thread_history_operation_in_runtime(
runtime: &Arc<RuntimeThreadManager>,
sessions_dir: &Path,
request: codewhale_protocol::CanonicalThreadOperationLookup,
@@ -1922,7 +1922,7 @@ pub(super) async fn recover_thread_history_operation(
/// Explicit exact-key recovery settles already-published preparation; it never
/// reconstructs an intent from a changed source. Lookup remains read-only.
pub(crate) async fn recover_thread_history_operation_in_runtime(
pub async fn recover_thread_history_operation_in_runtime(
runtime: &Arc<RuntimeThreadManager>,
sessions_dir: &Path,
request: codewhale_protocol::CanonicalThreadOperationRecovery,
@@ -84,11 +84,11 @@ pub(super) async fn read_turn_artifact(
let artifact = select_reference(&view, &artifact_id, query.revision.as_deref())?;
let workspace = view.workspace.clone();
let thread_workspace = view.thread_workspace.clone();
#[cfg(test)]
let env_ticket = crate::test_support::env_scope_ticket();
#[cfg(any(test, feature = "test-support"))]
let env_ticket = codewhale_engine::test_support::env_scope_ticket();
tokio::task::spawn_blocking(move || {
#[cfg(test)]
let _membership = crate::test_support::join_env_scope(env_ticket);
#[cfg(any(test, feature = "test-support"))]
let _membership = codewhale_engine::test_support::join_env_scope(env_ticket);
let (source, current, read) = match artifact.kind {
TurnArtifactKind::File => {
read_file_artifact(&thread_workspace, workspace.as_ref(), &artifact)?
@@ -415,11 +415,11 @@ pub(super) async fn list_call_changes(
// The side repo lives under the sealed test home; a blocking-pool thread
// is a foreign reader until it joins the test's env scope, and would
// otherwise resolve the isolated root and report every span pruned.
#[cfg(test)]
let env_ticket = crate::test_support::env_scope_ticket();
#[cfg(any(test, feature = "test-support"))]
let env_ticket = codewhale_engine::test_support::env_scope_ticket();
let read = tokio::task::spawn_blocking(move || {
#[cfg(test)]
let _membership = crate::test_support::join_env_scope(env_ticket);
#[cfg(any(test, feature = "test-support"))]
let _membership = codewhale_engine::test_support::join_env_scope(env_ticket);
call_span_files(
&workspace,
&pre,
File renamed without changes.
@@ -71,7 +71,7 @@ fn take_state_persist_failure(path: &Path) -> bool {
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub(crate) struct RuntimeChatPrompt {
pub struct RuntimeChatPrompt {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub structured_review: Option<codewhale_protocol::op::ReviewOperation>,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -106,19 +106,19 @@ pub(crate) struct RuntimeChatPrompt {
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub(crate) struct RuntimeChatWorkspace {
pub struct RuntimeChatWorkspace {
pub id: String,
pub target_ref: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct RuntimeChatControlScope {
pub struct RuntimeChatControlScope {
pub(crate) runtime_binding_id: String,
pub(crate) runtime_thread_id: String,
}
#[derive(Debug, Clone)]
pub(crate) struct RuntimeChatProjection {
pub struct RuntimeChatProjection {
pub run_id: String,
pub native_thread_id: String,
pub native_seq: u64,
File renamed without changes.
File renamed without changes.
File renamed without changes.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

+355 -119
View File
@@ -1,10 +1,11 @@
use crate::config_rpc::process_app_request;
use crate::turns::{BridgedTurn, handle_prompt_request, handle_thread_request, run_bridged_turn};
use crate::turns::{NativeTurn, handle_prompt_request, handle_thread_request, run_native_turn};
use crate::{
AppState, AppTransport, ConfigGetParams, ConfigSetParams, InFlightTurn, JsonRpcError,
JsonRpcRequest, RuntimeThreadHint, ShutdownAuthority, StdioDispatchResult, StdioLoopExit,
StdioLoopPolicy, ThreadIdParams, ThreadInterruptParams, ThreadMessageParams, daemon_socket,
jsonrpc_error, jsonrpc_result, legacy_deepseek_compat, params_or_object, parse_params,
AppState, AppTransport, ConfigGetParams, ConfigSetParams, DispatchError, InFlightTurn, RpcCall,
RuntimeOwnerRequest, RuntimeThreadHint, ShutdownAuthority, StdioDispatchResult, StdioLoopExit,
StdioLoopPolicy, ThreadIdParams, ThreadInterruptParams, ThreadMessageParams,
TurnExecutionOwner, TurnRegistry, daemon_socket, jsonrpc_error, jsonrpc_result,
legacy_deepseek_compat, params_or_object, parse_params, thread_control,
};
use anyhow::Context as _;
use anyhow::Result;
@@ -19,6 +20,7 @@ use std::collections::VecDeque;
use std::time::Duration;
use tokio::io::{AsyncBufRead, AsyncWrite};
use tokio::io::{AsyncBufReadExt as _, AsyncWriteExt as _};
use tokio_util::sync::CancellationToken;
#[derive(Debug, thiserror::Error)]
pub enum FrameRefusal {
@@ -148,6 +150,7 @@ where
let mut pending: VecDeque<(PendingStdioWork, usize)> = VecDeque::new();
let mut pending_bytes = 0usize;
let mut stdin_open = true;
let mut connection = crate::connection::ConnectionSession::default();
loop {
let next = pending.pop_front().map(|(work, bytes)| {
@@ -169,10 +172,7 @@ where
};
match parse_stdio_line(&line) {
ParsedStdioLine::Blank => continue,
ParsedStdioLine::Rejected(response) => {
write_stdio_line(&mut writer, &response).await?;
continue;
}
ParsedStdioLine::Ignored | ParsedStdioLine::Response(_) => continue,
ParsedStdioLine::Request(request) => request,
}
}
@@ -182,42 +182,86 @@ where
drop(input_claim.take());
return Ok(StdioLoopExit::InputClosed);
}
let id = request.id.clone();
if request.method == "shutdown" && policy.shutdown == ShutdownAuthority::Denied {
let Some(id) = request.id.clone() else {
if request.method == "initialized" {
tracing::debug!("control initialized notification received");
}
continue;
};
if request.method == "initialize" {
let response = match connection.initialize(request.params) {
Ok(result) => jsonrpc_result(id, result),
Err(error) => jsonrpc_error(id, error),
};
write_stdio_line(&mut writer, &response).await?;
continue;
}
if !connection.initialized() {
write_stdio_line(
&mut writer,
&jsonrpc_error(id, JsonRpcError::not_daemon_owner()),
&jsonrpc_error(id, DispatchError::invalid_request("Not initialized")),
)
.await?;
continue;
}
let dispatched = if request.method == "thread/message" {
if request.method == "shutdown" && policy.shutdown == ShutdownAuthority::Denied {
write_stdio_line(
&mut writer,
&jsonrpc_error(id, DispatchError::not_daemon_owner()),
)
.await?;
continue;
}
let mut input_error: Option<anyhow::Error> = None;
let native_request = matches!(
request.method.as_str(),
"thread/message" | "prompt/request" | "prompt/run"
) || (request.method == "thread/request"
&& request.params.get("kind").and_then(Value::as_str) == Some("message"));
let dispatched = if native_request {
// A turn can run for minutes. Keep reading stdin while it streams
// so an interrupt (or a shutdown) can actually reach it — with a
// plain `await` here, nothing could be read until it finished.
let cancellation = CancellationToken::new();
let _cancel_on_drop = cancellation.clone().drop_guard();
let dispatch = dispatch_stdio_request_with_writer(
state,
&mut writer,
&request.method,
request.params,
policy.transport,
Some(cancellation.clone()),
);
tokio::pin!(dispatch);
loop {
tokio::select! {
outcome = &mut dispatch => break outcome,
line = reader.next_line(), if stdin_open => {
match line? {
None => {
match line {
Err(error) => {
stdin_open = false;
drop(input_claim.take());
cancellation.cancel();
input_error = Some(error.into());
}
Ok(None) => {
stdin_open = false;
// Release the claim here, not after `dispatch`
// resolves.
drop(input_claim.take());
if policy.transport == AppTransport::Socket {
cancellation.cancel();
}
}
Some(line) => {
Ok(Some(line)) => {
if matches!(parse_stdio_line(&line), ParsedStdioLine::Request(ref request) if is_control_detach(request)) {
stdin_open=false; drop(input_claim.take());
} else {handle_line_during_turn(state, &line, &mut pending, &mut pending_bytes, policy).await?;}
} else if let Err(error) = handle_line_during_turn(state, &line, &mut pending, &mut pending_bytes, policy).await {
stdin_open = false;
drop(input_claim.take());
cancellation.cancel();
input_error = Some(error);
}
}
}
}
@@ -230,10 +274,21 @@ where
&request.method,
request.params,
policy.transport,
None,
)
.await
};
if let Some(error) = input_error {
if let Err(dispatch_error) = &dispatched {
let diagnostic = error.to_string();
return Err(error.context(format!(
"control input failed: {diagnostic}; native request cleanup: {}",
dispatch_error.wire.message
)));
}
return Err(error);
}
match dispatched {
Ok(dispatch) => {
write_stdio_line(&mut writer, &jsonrpc_result(id, dispatch.result)).await?;
@@ -256,7 +311,7 @@ pub(crate) enum PendingStdioWork {
/// Already answered (an interrupt acted immediately); just needs writing.
Response(Value),
/// Not started yet; runs normally once the turn is done.
Request(JsonRpcRequest),
Request(RpcCall),
}
/// Fixed retained queue limits; exhaustion is a visible connection refusal
@@ -281,40 +336,34 @@ pub(crate) fn queue_stdio_work(
pub(crate) enum ParsedStdioLine {
Blank,
Request(JsonRpcRequest),
Rejected(Value),
Request(RpcCall),
Ignored,
Response(Value),
}
pub(crate) fn parse_stdio_line(line: &str) -> ParsedStdioLine {
if line.len() > MAX_RUNTIME_IMAGE_BODY_BYTES {
return ParsedStdioLine::Rejected(jsonrpc_error(
None,
JsonRpcError::invalid_params("request exceeds the 8 MiB transport limit"),
));
return ParsedStdioLine::Ignored;
}
if line.trim().is_empty() {
return ParsedStdioLine::Blank;
}
let request: JsonRpcRequest = match serde_json::from_str(line) {
Ok(value) => value,
Err(err) => {
return ParsedStdioLine::Rejected(jsonrpc_error(
None,
JsonRpcError::parse_error(format!("invalid json: {err}")),
));
let message: codewhale_app_server_protocol::rpc::RpcMessage = match serde_json::from_str(line) {
Ok(message) => message,
Err(error) => {
tracing::debug!(%error, "discarding malformed control RPC message");
return ParsedStdioLine::Ignored;
}
};
if request
.jsonrpc
.as_deref()
.is_some_and(|version| version != "2.0")
{
return ParsedStdioLine::Rejected(jsonrpc_error(
request.id,
JsonRpcError::invalid_request("jsonrpc version must be 2.0"),
));
match message {
codewhale_app_server_protocol::rpc::RpcMessage::Response(response) => {
ParsedStdioLine::Response(json!(response))
}
codewhale_app_server_protocol::rpc::RpcMessage::Error(response) => {
ParsedStdioLine::Response(json!(response))
}
message => ParsedStdioLine::Request(message.into_call().expect("call variant")),
}
ParsedStdioLine::Request(request)
}
/// Triage a request that arrived mid-turn.
@@ -334,20 +383,16 @@ pub(crate) async fn handle_line_during_turn(
) -> Result<()> {
let request = match parse_stdio_line(line) {
ParsedStdioLine::Blank => return Ok(()),
ParsedStdioLine::Rejected(response) => {
return queue_stdio_work(
pending,
pending_bytes,
PendingStdioWork::Response(response),
line.len(),
);
}
ParsedStdioLine::Ignored | ParsedStdioLine::Response(_) => return Ok(()),
ParsedStdioLine::Request(request) => request,
};
let Some(id) = request.id.clone() else {
return Ok(());
};
match request.method.as_str() {
"thread/interrupt" => {
let id = request.id.clone();
let response = match parse_params::<ThreadInterruptParams>(params_or_object(
request.params.clone(),
)) {
@@ -373,10 +418,7 @@ pub(crate) async fn handle_line_during_turn(
queue_stdio_work(
pending,
pending_bytes,
PendingStdioWork::Response(jsonrpc_error(
request.id,
JsonRpcError::not_daemon_owner(),
)),
PendingStdioWork::Response(jsonrpc_error(id, DispatchError::not_daemon_owner())),
line.len(),
)?;
}
@@ -399,9 +441,8 @@ pub(crate) async fn handle_line_during_turn(
Ok(())
}
pub(crate) fn is_control_detach(request: &JsonRpcRequest) -> bool {
request.jsonrpc.as_deref() == Some("2.0")
&& request.id.is_none()
pub(crate) fn is_control_detach(request: &RpcCall) -> bool {
request.id.is_none()
&& request.method == "daemon/input_closed"
&& request
.params
@@ -501,11 +542,12 @@ pub(crate) async fn handle_stdio_thread_message<W: AsyncWrite + Unpin>(
state: &AppState,
writer: &mut W,
parsed: ThreadMessageParams,
) -> std::result::Result<Value, JsonRpcError> {
let mut result = run_bridged_turn(
cancellation: Option<CancellationToken>,
) -> std::result::Result<Value, DispatchError> {
let mut result = run_native_turn(
state,
writer,
BridgedTurn {
NativeTurn {
allowed_tools: parsed.allowed_tools.as_deref(),
max_output_tokens: parsed.max_output_tokens,
thread_key: &parsed.thread_id,
@@ -514,7 +556,7 @@ pub(crate) async fn handle_stdio_thread_message<W: AsyncWrite + Unpin>(
model_override: None,
interruptible: true,
ephemeral: false,
require_known_thread: true,
cancellation,
},
None,
)
@@ -531,9 +573,9 @@ pub(crate) async fn handle_stdio_thread_message<W: AsyncWrite + Unpin>(
/// the same thread id (#5171).
pub(crate) fn ensure_thread_found(
response: &ThreadResponse,
) -> std::result::Result<(), JsonRpcError> {
) -> std::result::Result<(), DispatchError> {
if response.status == "missing" {
return Err(JsonRpcError::thread_not_found(&response.thread_id));
return Err(DispatchError::thread_not_found(&response.thread_id));
}
Ok(())
}
@@ -549,66 +591,170 @@ pub(crate) async fn record_stdio_thread_hint(state: &AppState, response: &Thread
);
}
/// Ask the runtime to interrupt a turn that is streaming right now.
///
/// Everything this needs was copied out of the bridge when the turn started,
/// so it never touches the bridge mutex the turn is holding. Returns whether
/// a live turn was found for `thread_id`.
/// Interrupt one in-flight turn over HTTP, from an owned snapshot.
///
/// Split from [`interrupt_stdio_turn`] so teardown paths can run many
/// concurrently (#6211 R8b) — each future owns its snapshot and never holds
/// the turn registry across the request.
pub(crate) async fn interrupt_turn_request(
turn: &InFlightTurn,
) -> std::result::Result<bool, JsonRpcError> {
let mut request = codewhale_release::platform_http_client_builder()
.redirect(reqwest::redirect::Policy::none())
.timeout(Duration::from_secs(10))
.build()
.map_err(|err| JsonRpcError::internal(err.to_string()))?
.post(format!(
"{}/v1/threads/{}/turns/{}/interrupt",
turn.base_url, turn.runtime_thread_id, turn.turn_id
));
if let Some(token) = turn.auth_token.as_deref() {
request = request.bearer_auth(token);
) -> std::result::Result<bool, DispatchError> {
match &turn.owner {
TurnExecutionOwner::Native(owner) => {
let interrupted = match tokio::time::timeout(
Duration::from_secs(10),
owner.execute(RuntimeOwnerRequest::InterruptTurn {
thread_id: turn.runtime_thread_id.clone(),
turn_id: turn.turn_id.clone(),
}),
)
.await
{
Ok(result) => result
.map_err(|error| DispatchError::internal(format!("interrupt failed: {error}"))),
Err(error) => Err(DispatchError::internal(format!(
"interrupt outcome uncertain: {error}"
))),
};
match interrupted {
Ok(_) => Ok(true),
Err(error) => {
let detail = crate::owner_calls::execute(
owner,
RuntimeOwnerRequest::GetThread {
thread_id: turn.runtime_thread_id.clone(),
},
)
.await;
let terminal = detail.as_ref().ok().is_some_and(|detail| {
detail.pointer("/thread/id").and_then(Value::as_str)
== Some(turn.runtime_thread_id.as_str())
&& detail
.get("turns")
.and_then(Value::as_array)
.is_some_and(|turns| {
turns.iter().any(|record| {
record.get("id").and_then(Value::as_str)
== Some(turn.turn_id.as_str())
&& record.get("thread_id").and_then(Value::as_str)
== Some(turn.runtime_thread_id.as_str())
&& matches!(
record.get("status").and_then(Value::as_str),
Some(
"completed"
| "failed"
| "interrupted"
| "canceled"
)
)
})
})
});
if terminal {
Ok(false)
} else {
Err(DispatchError::internal(format!(
"{}; exact native terminal is unconfirmed for thread {}, turn {}",
error.wire.message, turn.runtime_thread_id, turn.turn_id
)))
}
}
}
}
#[cfg(test)]
TurnExecutionOwner::HistoricalHttp {
base_url,
auth_token,
} => {
let mut request = codewhale_release::platform_http_client_builder()
.redirect(reqwest::redirect::Policy::none())
.timeout(Duration::from_secs(10))
.build()
.map_err(|err| DispatchError::internal(err.to_string()))?
.post(format!(
"{}/v1/threads/{}/turns/{}/interrupt",
base_url, turn.runtime_thread_id, turn.turn_id
));
if let Some(token) = auth_token.as_deref() {
request = request.bearer_auth(token);
}
request
.send()
.await
.and_then(reqwest::Response::error_for_status)
.map_err(|err| DispatchError::internal(format!("interrupt failed: {err}")))?;
Ok(true)
}
}
request
.send()
.await
.and_then(reqwest::Response::error_for_status)
.map_err(|err| JsonRpcError::internal(format!("interrupt failed: {err}")))?;
Ok(true)
}
pub(crate) async fn interrupt_stdio_turn(
state: &AppState,
thread_id: &str,
) -> std::result::Result<bool, JsonRpcError> {
) -> std::result::Result<bool, DispatchError> {
let Some(turn) = state.in_flight_turns.lock().await.get(thread_id).cloned() else {
return Ok(false);
};
interrupt_turn_request(&turn).await
let interrupted = interrupt_turn_request(&turn).await?;
retire_turn_registration(&state.in_flight_turns, thread_id, &turn).await;
Ok(interrupted)
}
pub(crate) async fn retire_turn_registration(
registry: &TurnRegistry,
key: &str,
expected: &InFlightTurn,
) {
let mut registered = registry.lock().await;
if registered.get(key).is_some_and(|current| {
current.runtime_thread_id == expected.runtime_thread_id
&& current.turn_id == expected.turn_id
&& match (&current.owner, &expected.owner) {
(TurnExecutionOwner::Native(current), TurnExecutionOwner::Native(expected)) => {
std::sync::Arc::ptr_eq(current, expected)
}
#[cfg(test)]
(
TurnExecutionOwner::HistoricalHttp {
base_url: current,
auth_token: current_auth,
},
TurnExecutionOwner::HistoricalHttp {
base_url: expected,
auth_token: expected_auth,
},
) => current == expected && current_auth == expected_auth,
#[cfg(test)]
_ => false,
}
}) {
registered.remove(key);
}
}
/// Interrupt every in-flight turn concurrently, reporting how many were
/// reached (#6211 R8b).
///
/// The teardown paths used to await each turn's interrupt in sequence, so
/// their latency grew with the number of live turns — up to the 10s
/// per-request timeout apiece. Each interrupt now owns its snapshot and runs
/// their latency grew with the number of live turns, including each
/// interrupt deadline and bounded readback after uncertain outcomes. Each interrupt now owns its snapshot and runs
/// as an independent task; individual failures are ignored exactly as the
/// sequential loop ignored them, and the registry lock is never held across
/// the requests.
pub(crate) async fn interrupt_all_stdio_turns(state: &AppState) -> usize {
let turns: Vec<InFlightTurn> = {
let turns: Vec<(String, InFlightTurn)> = {
let map = state.in_flight_turns.lock().await;
map.values().cloned().collect()
map.iter()
.map(|(key, turn)| (key.clone(), turn.clone()))
.collect()
};
let mut set = tokio::task::JoinSet::new();
for turn in turns {
set.spawn(async move { interrupt_turn_request(&turn).await });
for (key, turn) in turns {
let registry = state.in_flight_turns.clone();
set.spawn(async move {
let interrupted = interrupt_turn_request(&turn).await?;
retire_turn_registration(&registry, &key, &turn).await;
Ok::<_, DispatchError>(interrupted)
});
}
let mut interrupted = 0usize;
while let Some(joined) = set.join_next().await {
@@ -631,20 +777,21 @@ pub(crate) async fn dispatch_stdio_request(
state: &AppState,
method: &str,
params: Value,
) -> std::result::Result<StdioDispatchResult, JsonRpcError> {
) -> std::result::Result<StdioDispatchResult, DispatchError> {
let mut sink = tokio::io::sink();
dispatch_stdio_request_with_writer(state, &mut sink, method, params, AppTransport::Stdio).await
dispatch_stdio_request_with_writer(state, &mut sink, method, params, AppTransport::Stdio, None)
.await
}
pub(crate) async fn dispatch_stdio_app_request(
state: &AppState,
request: AppRequest,
transport: AppTransport,
) -> std::result::Result<StdioDispatchResult, JsonRpcError> {
) -> std::result::Result<StdioDispatchResult, DispatchError> {
let response = Box::pin(process_app_request(state, request, transport)).await;
Ok(StdioDispatchResult {
result: serde_json::to_value(response)
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
.map_err(|err| DispatchError::internal(err.to_string()))?,
should_exit: false,
})
}
@@ -655,7 +802,8 @@ pub(crate) async fn dispatch_stdio_request_with_writer<W: AsyncWrite + Unpin>(
method: &str,
params: Value,
transport: AppTransport,
) -> std::result::Result<StdioDispatchResult, JsonRpcError> {
cancellation: Option<CancellationToken>,
) -> std::result::Result<StdioDispatchResult, DispatchError> {
let outcome = match method {
"healthz" | "app/healthz" => StdioDispatchResult {
result: json!({
@@ -675,7 +823,9 @@ pub(crate) async fn dispatch_stdio_request_with_writer<W: AsyncWrite + Unpin>(
"thread/resume",
"thread/fork",
"thread/list",
"thread/loaded/list",
"thread/read",
"thread/name/set",
"thread/set_name",
"thread/goal/set",
"thread/goal/get",
@@ -706,7 +856,13 @@ pub(crate) async fn dispatch_stdio_request_with_writer<W: AsyncWrite + Unpin>(
StdioDispatchResult {
result: json!({
"transport": transport.label(),
"framing": match transport {
AppTransport::Stdio => "jsonl",
AppTransport::Socket => "websocket-text",
AppTransport::Http => "http",
},
"families": ["thread/*", "app/*", "prompt/*"],
"initialization": {"required": true, "method": "initialize", "notification": "initialized"},
"turn_image_inputs": true,
"interactive_execution": false,
"goal_execution": false,
@@ -731,8 +887,10 @@ pub(crate) async fn dispatch_stdio_request_with_writer<W: AsyncWrite + Unpin>(
"thread/resume",
"thread/fork",
"thread/list",
"thread/loaded/list",
"thread/read",
"thread/set_name",
"thread/name/set",
"thread/set_name",
"thread/goal/set",
"thread/goal/get",
"thread/goal/clear",
@@ -764,6 +922,7 @@ pub(crate) async fn dispatch_stdio_request_with_writer<W: AsyncWrite + Unpin>(
images,
max_output_tokens,
},
cancellation.clone(),
)
.await?;
return Ok(StdioDispatchResult {
@@ -784,7 +943,7 @@ pub(crate) async fn dispatch_stdio_request_with_writer<W: AsyncWrite + Unpin>(
}
StdioDispatchResult {
result: serde_json::to_value(response)
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
.map_err(|err| DispatchError::internal(err.to_string()))?,
should_exit: false,
}
}
@@ -805,7 +964,7 @@ pub(crate) async fn dispatch_stdio_request_with_writer<W: AsyncWrite + Unpin>(
record_stdio_thread_hint(state, &response).await;
StdioDispatchResult {
result: serde_json::to_value(response)
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
.map_err(|err| DispatchError::internal(err.to_string()))?,
should_exit: false,
}
}
@@ -815,7 +974,7 @@ pub(crate) async fn dispatch_stdio_request_with_writer<W: AsyncWrite + Unpin>(
record_stdio_thread_hint(state, &response).await;
StdioDispatchResult {
result: serde_json::to_value(response)
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
.map_err(|err| DispatchError::internal(err.to_string()))?,
should_exit: false,
}
}
@@ -826,7 +985,7 @@ pub(crate) async fn dispatch_stdio_request_with_writer<W: AsyncWrite + Unpin>(
record_stdio_thread_hint(state, &response).await;
StdioDispatchResult {
result: serde_json::to_value(response)
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
.map_err(|err| DispatchError::internal(err.to_string()))?,
should_exit: false,
}
}
@@ -837,7 +996,7 @@ pub(crate) async fn dispatch_stdio_request_with_writer<W: AsyncWrite + Unpin>(
record_stdio_thread_hint(state, &response).await;
StdioDispatchResult {
result: serde_json::to_value(response)
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
.map_err(|err| DispatchError::internal(err.to_string()))?,
should_exit: false,
}
}
@@ -846,7 +1005,63 @@ pub(crate) async fn dispatch_stdio_request_with_writer<W: AsyncWrite + Unpin>(
let response = handle_thread_request(state, request).await?;
StdioDispatchResult {
result: serde_json::to_value(response)
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
.map_err(|err| DispatchError::internal(err.to_string()))?,
should_exit: false,
}
}
"thread/loaded/list" => {
let params: codewhale_app_server_protocol::rpc::ThreadLoadedListParams =
parse_params(params_or_object(params))?;
let limit = params.limit.map(usize::try_from).transpose().map_err(|_| {
DispatchError::invalid_params("loaded thread limit exceeds platform size")
})?;
let cursor = params
.cursor
.as_deref()
.map(|cursor| {
cursor
.strip_prefix("loaded-v1:")
.filter(|id| id.len() <= 1024 && !id.chars().any(char::is_control))
.ok_or_else(|| {
DispatchError::invalid_params("invalid loaded thread cursor")
})
})
.transpose()?;
let loaded = thread_control::loaded(state)
.await
.map_err(|error| thread_control::rpc_error(error, None))?;
let start = cursor.filter(|id| !id.is_empty()).map_or(0, |cursor| {
loaded
.threads
.partition_point(|thread| thread.id.as_str() <= cursor)
});
let remaining = &loaded.threads[start..];
let count = limit.unwrap_or(remaining.len()).min(remaining.len());
let data = remaining[..count]
.iter()
.map(|thread| thread.id.clone())
.collect::<Vec<_>>();
let next_cursor = if count < remaining.len() {
Some(data.last().map_or_else(
|| {
params
.cursor
.clone()
.unwrap_or_else(|| "loaded-v1:".to_owned())
},
|id| format!("loaded-v1:{id}"),
))
} else {
None
};
StdioDispatchResult {
result: serde_json::to_value(
codewhale_app_server_protocol::rpc::ThreadLoadedListResponse {
data,
next_cursor,
},
)
.map_err(|error| DispatchError::internal(error.to_string()))?,
should_exit: false,
}
}
@@ -855,7 +1070,26 @@ pub(crate) async fn dispatch_stdio_request_with_writer<W: AsyncWrite + Unpin>(
let response = handle_thread_request(state, request).await?;
StdioDispatchResult {
result: serde_json::to_value(response)
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
.map_err(|err| DispatchError::internal(err.to_string()))?,
should_exit: false,
}
}
"thread/name/set" => {
let params: codewhale_app_server_protocol::rpc::ThreadSetNameParams =
parse_params(params_or_object(params))?;
handle_thread_request(
state,
ThreadRequest::SetName(codewhale_protocol::ThreadSetNameParams {
thread_id: params.thread_id,
name: params.name,
}),
)
.await?;
StdioDispatchResult {
result: serde_json::to_value(
codewhale_app_server_protocol::rpc::ThreadSetNameResponse::default(),
)
.map_err(|error| DispatchError::internal(error.to_string()))?,
should_exit: false,
}
}
@@ -864,7 +1098,7 @@ pub(crate) async fn dispatch_stdio_request_with_writer<W: AsyncWrite + Unpin>(
let response = handle_thread_request(state, request).await?;
StdioDispatchResult {
result: serde_json::to_value(response)
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
.map_err(|err| DispatchError::internal(err.to_string()))?,
should_exit: false,
}
}
@@ -875,7 +1109,7 @@ pub(crate) async fn dispatch_stdio_request_with_writer<W: AsyncWrite + Unpin>(
let response = handle_thread_request(state, request).await?;
StdioDispatchResult {
result: serde_json::to_value(response)
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
.map_err(|err| DispatchError::internal(err.to_string()))?,
should_exit: false,
}
}
@@ -886,7 +1120,7 @@ pub(crate) async fn dispatch_stdio_request_with_writer<W: AsyncWrite + Unpin>(
let response = handle_thread_request(state, request).await?;
StdioDispatchResult {
result: serde_json::to_value(response)
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
.map_err(|err| DispatchError::internal(err.to_string()))?,
should_exit: false,
}
}
@@ -897,7 +1131,7 @@ pub(crate) async fn dispatch_stdio_request_with_writer<W: AsyncWrite + Unpin>(
let response = handle_thread_request(state, request).await?;
StdioDispatchResult {
result: serde_json::to_value(response)
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
.map_err(|err| DispatchError::internal(err.to_string()))?,
should_exit: false,
}
}
@@ -913,7 +1147,7 @@ pub(crate) async fn dispatch_stdio_request_with_writer<W: AsyncWrite + Unpin>(
ensure_thread_found(&response)?;
StdioDispatchResult {
result: serde_json::to_value(response)
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
.map_err(|err| DispatchError::internal(err.to_string()))?,
should_exit: false,
}
}
@@ -929,13 +1163,14 @@ pub(crate) async fn dispatch_stdio_request_with_writer<W: AsyncWrite + Unpin>(
ensure_thread_found(&response)?;
StdioDispatchResult {
result: serde_json::to_value(response)
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
.map_err(|err| DispatchError::internal(err.to_string()))?,
should_exit: false,
}
}
"thread/message" => {
let parsed: ThreadMessageParams = parse_params(params_or_object(params))?;
let response = handle_stdio_thread_message(state, writer, parsed).await?;
let response =
handle_stdio_thread_message(state, writer, parsed, cancellation.clone()).await?;
StdioDispatchResult {
result: response,
should_exit: false,
@@ -996,10 +1231,11 @@ pub(crate) async fn dispatch_stdio_request_with_writer<W: AsyncWrite + Unpin>(
},
"prompt/request" | "prompt/run" => {
let request: PromptRequest = parse_params(params)?;
let response = handle_prompt_request(state, writer, request).await?;
let response =
handle_prompt_request(state, writer, request, cancellation.clone()).await?;
StdioDispatchResult {
result: serde_json::to_value(response)
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
.map_err(|err| DispatchError::internal(err.to_string()))?,
should_exit: false,
}
}
@@ -1026,9 +1262,9 @@ pub(crate) async fn dispatch_stdio_request_with_writer<W: AsyncWrite + Unpin>(
}
}
daemon_socket::ATTACH_METHOD if transport == AppTransport::Socket => {
return Err(JsonRpcError::already_attached());
return Err(DispatchError::already_attached());
}
_ => return Err(JsonRpcError::method_not_found(method)),
_ => return Err(DispatchError::method_not_found(method)),
};
Ok(outcome)
}
File diff suppressed because it is too large. Load diff
+144 -116
View File
@@ -1,7 +1,7 @@
//! Compatibility control projection onto the already held canonical owner.
//! SQLite supplies immutable migration input and committed alias receipts only.
use super::*;
use crate::bridge::acquire_live_runtime_bridge;
use crate::owner_calls;
use anyhow::{Context as _, bail};
#[cfg(test)]
use axum::Router;
@@ -14,6 +14,7 @@ use codewhale_protocol::{
RuntimeOwnerReceipt, SessionSource, Thread, ThreadStatus,
};
#[cfg(test)]
#[derive(Debug, thiserror::Error)]
#[error("runtime API returned {status}: {detail}")]
struct HttpFailure {
@@ -49,17 +50,8 @@ fn owner(state: &AppState) -> Result<RuntimeOwnerReceipt> {
)
}
fn endpoint(bridge: &RuntimeBridge, segments: &[&str]) -> Result<reqwest::Url> {
let mut url = reqwest::Url::parse(&bridge.base_url)?;
url.path_segments_mut()
.map_err(|_| anyhow!("invalid canonical owner URL"))?
.clear()
.extend(segments.iter().copied());
Ok(url)
}
/// Reused by every bridge JSON read, including full canonical history. The
/// declared length and actual streamed bytes must both fit; nothing is cut.
#[cfg(test)]
pub(super) async fn read_json_response(mut response: reqwest::Response) -> Result<Value> {
let status = response.status();
if response
@@ -94,24 +86,24 @@ pub(super) async fn read_json_response(mut response: reqwest::Response) -> Resul
}
async fn request(
bridge: &RuntimeBridge,
method: Method,
segments: &[&str],
body: Option<&Value>,
owner: &Arc<dyn RuntimeOwnerFrontend>,
operation: RuntimeOwnerRequest,
) -> Result<Value> {
let mut request = bridge.authed(bridge.client.request(method, endpoint(bridge, segments)?));
if let Some(body) = body {
anyhow::ensure!(
serde_json::to_vec(body)?.len() <= MAX_CANONICAL_HISTORY_BYTES,
"encoded canonical control exceeds bound"
);
request = request.json(body);
}
tokio::time::timeout(Duration::from_secs(30), bridge.request_json(request))
.await
.context(
"canonical control deadline expired; outcome may be committed, no automatic replay",
)?
let encoded = match &operation {
RuntimeOwnerRequest::CreateThread(body)
| RuntimeOwnerRequest::UpdateThread { body, .. }
| RuntimeOwnerRequest::StartTurn { body, .. } => Some(serde_json::to_vec(body)?.len()),
RuntimeOwnerRequest::HistoryImport(body) => Some(serde_json::to_vec(body)?.len()),
RuntimeOwnerRequest::HistoryMutate(body) => Some(serde_json::to_vec(body)?.len()),
RuntimeOwnerRequest::HistoryLookup(body) => Some(serde_json::to_vec(body)?.len()),
RuntimeOwnerRequest::HistoryRecover(body) => Some(serde_json::to_vec(body)?.len()),
_ => None,
};
anyhow::ensure!(
encoded.is_none_or(|len| len <= MAX_CANONICAL_HISTORY_BYTES),
"encoded canonical control exceeds bound"
);
owner_calls::execute(owner, operation).await
}
#[cfg(any(unix, windows))]
@@ -208,7 +200,6 @@ fn check_workspace(state: &AppState, workspace: &Path) -> Result<()> {
Ok(())
}
/// Holds the existing bridge serialization through import and exact source
/// CAS. A cancelled waiter does not cancel publication of a committed result.
pub(super) async fn resolve(
state: &AppState,
@@ -229,9 +220,7 @@ pub(super) async fn resolve(
async fn resolve_owned(state: &AppState, key: &str, execution: bool) -> Result<(String, PathBuf)> {
let owner = owner(state)?;
let source = legacy_source(state, key, &owner).await?;
let bridge = acquire_live_runtime_bridge(state)
.await
.map_err(|e| anyhow!("{}", e.message))?;
let native = owner_calls::captured(state)?;
let id = if let Some((store, source)) = source {
if execution || source.receipt.is_none() {
check_workspace(state, &source.metadata.cwd)?;
@@ -253,7 +242,7 @@ async fn resolve_owned(state: &AppState, key: &str, execution: bool) -> Result<(
model: None,
history: history.clone(),
};
let value = request(&bridge, Method::POST, &["v1", "thread-history", "import"], Some(&serde_json::to_value(request_body)?)).await
let value = request(&native, RuntimeOwnerRequest::HistoryImport(request_body)).await
.with_context(|| format!("canonical import {operation} may have committed; inspect or retry the same operation, never remint"))?;
let receipt: CanonicalThreadReceipt = serde_json::from_value(value)?;
verify_receipt(&receipt, &owner, &operation)?;
@@ -267,11 +256,16 @@ async fn resolve_owned(state: &AppState, key: &str, execution: bool) -> Result<(
} else {
key.to_owned()
};
let value = request(&bridge, Method::GET, &["v1", "threads", &id], None)
.await
.context(
"canonical target is unavailable; existing identity retained, no replacement thread",
)?;
let value = request(
&native,
RuntimeOwnerRequest::GetThread {
thread_id: id.clone(),
},
)
.await
.context(
"canonical target is unavailable; existing identity retained, no replacement thread",
)?;
let record = value.get("thread").unwrap_or(&value);
anyhow::ensure!(
record.get("id").and_then(Value::as_str) == Some(id.as_str()),
@@ -281,11 +275,6 @@ async fn resolve_owned(state: &AppState, key: &str, execution: bool) -> Result<(
if execution {
check_workspace(state, &thread.cwd)?;
}
state
.runtime_thread_map
.lock()
.await
.insert(key.to_owned(), id.clone());
Ok((id, thread.cwd))
}
@@ -355,6 +344,41 @@ fn response(id: String) -> ThreadResponse {
}
}
pub(super) async fn loaded(state: &AppState) -> Result<ThreadResponse> {
let native = owner_calls::captured(state)?;
let rows = request(&native, RuntimeOwnerRequest::LoadedThreads).await?;
let rows = rows
.as_array()
.context("loaded thread snapshot is not an array")?;
anyhow::ensure!(
rows.len() <= MAX_CANONICAL_HISTORY_ENTRIES,
"loaded thread snapshot exceeds bound"
);
let active = running_ids(&native).await?;
let mut result = response("loaded".into());
let mut ids = std::collections::HashSet::new();
for row in rows {
let id = row
.get("id")
.and_then(Value::as_str)
.context("loaded thread identity missing")?;
anyhow::ensure!(
!id.is_empty()
&& id.len() <= 1024
&& !id.chars().any(char::is_control)
&& ids.insert(id.to_owned()),
"loaded thread identity invalid or repeated"
);
let mut thread = project_thread(row, id)?;
if active.contains(id) && thread.status != ThreadStatus::Archived {
thread.status = ThreadStatus::Running;
}
result.threads.push(thread);
}
result.threads.sort_by(|left, right| left.id.cmp(&right.id));
Ok(result)
}
async fn list(
state: &AppState,
params: codewhale_protocol::ThreadListParams,
@@ -383,16 +407,15 @@ async fn list(
limit <= MAX_CANONICAL_HISTORY_ENTRIES,
"thread list exceeds bound"
);
let bridge = acquire_live_runtime_bridge(state)
.await
.map_err(|e| anyhow!("{}", e.message))?;
let mut url = endpoint(&bridge, &["v1", "threads"])?;
url.query_pairs_mut()
.append_pair("include_archived", "true")
.append_pair("limit", &(MAX_CANONICAL_HISTORY_ENTRIES + 1).to_string());
let rows = bridge
.request_json(bridge.authed(bridge.client.get(url)))
.await?;
let native = owner_calls::captured(state)?;
let rows = request(
&native,
RuntimeOwnerRequest::ListThreads {
include_archived: true,
limit: MAX_CANONICAL_HISTORY_ENTRIES + 1,
},
)
.await?;
let rows = rows
.as_array()
.context("canonical thread list is not an array")?;
@@ -401,7 +424,7 @@ async fn list(
"canonical thread list exceeds bound"
);
let mut canonical = HashMap::new();
let active = running_ids(&bridge).await?;
let active = running_ids(&native).await?;
for row in rows {
let id = row
.get("id")
@@ -467,7 +490,7 @@ async fn list(
pub(super) async fn handle(
state: &AppState,
request_value: ThreadRequest,
) -> std::result::Result<ThreadResponse, JsonRpcError> {
) -> std::result::Result<ThreadResponse, DispatchError> {
let key = match &request_value {
ThreadRequest::Read(p) => Some(p.thread_id.as_str()),
ThreadRequest::SetName(p) => Some(p.thread_id.as_str()),
@@ -488,7 +511,7 @@ pub(super) async fn handle(
.await
.map_err(|error| rpc_error(error, key.as_deref()))?;
if serde_json::to_vec(&result)
.map_err(|e| JsonRpcError::internal(e.to_string()))?
.map_err(|e| DispatchError::internal(e.to_string()))?
.len()
> MAX_CANONICAL_HISTORY_BYTES
{
@@ -499,26 +522,28 @@ pub(super) async fn handle(
.and_then(|value| serde_json::from_value::<CanonicalThreadReceipt>(value.clone()).ok())
.map(|receipt| committed_failure(&receipt, anyhow!(message)))
.unwrap_or_else(|| anyhow!(message));
return Err(JsonRpcError::runtime_unavailable(format!("{error:#}")));
return Err(DispatchError::runtime_unavailable(format!("{error:#}")));
}
Ok(result)
}
pub(super) fn rpc_error(error: anyhow::Error, key: Option<&str>) -> JsonRpcError {
pub(super) fn rpc_error(error: anyhow::Error, key: Option<&str>) -> DispatchError {
if error.downcast_ref::<CommittedControlFailure>().is_some() {
JsonRpcError::runtime_unavailable(format!("{error:#}"))
DispatchError::runtime_unavailable(format!("{error:#}"))
} else if error
.downcast_ref::<HttpFailure>()
.is_some_and(|error| error.status == StatusCode::NOT_FOUND)
.downcast_ref::<RuntimeOwnerFailure>()
.is_some_and(|error| error.status == StatusCode::NOT_FOUND.as_u16())
{
JsonRpcError::thread_not_found(key.unwrap_or("unknown"))
DispatchError::thread_not_found(key.unwrap_or("unknown"))
} else {
JsonRpcError::runtime_unavailable(format!("{error:#}"))
DispatchError::runtime_unavailable(format!("{error:#}"))
}
}
async fn running_ids(bridge: &RuntimeBridge) -> Result<std::collections::HashSet<String>> {
let value = request(bridge, Method::GET, &["v1", "threads", "running"], None).await?;
async fn running_ids(
native: &Arc<dyn RuntimeOwnerFrontend>,
) -> Result<std::collections::HashSet<String>> {
let value = request(native, RuntimeOwnerRequest::RunningThreads).await?;
let rows = value
.as_array()
.context("canonical running list is not an array")?;
@@ -555,11 +580,17 @@ fn selected_workspace(state: &AppState, explicit: Option<PathBuf>) -> Result<Pat
async fn full_snapshot(
state: &AppState,
bridge: &RuntimeBridge,
native: &Arc<dyn RuntimeOwnerFrontend>,
id: &str,
) -> Result<CanonicalThreadSnapshot> {
let snapshot: CanonicalThreadSnapshot = serde_json::from_value(
request(bridge, Method::GET, &["v1", "threads", id, "history"], None).await?,
request(
native,
RuntimeOwnerRequest::HistorySnapshot {
thread_id: id.to_owned(),
},
)
.await?,
)?;
let selected = owner(state)?;
anyhow::ensure!(
@@ -582,9 +613,7 @@ async fn mutate(
) -> Result<ThreadResponse> {
let owner = owner(state)?;
check_workspace(state, &workspace)?;
let bridge = acquire_live_runtime_bridge(state)
.await
.map_err(|error| anyhow!("{}", error.message))?;
let native = owner_calls::captured(state)?;
let body = CanonicalThreadMutationRequest {
version: 1,
operation_key: operation.clone(),
@@ -593,12 +622,7 @@ async fn mutate(
workspace,
mutation,
};
let value = request(
&bridge,
Method::POST,
&["v1", "thread-history", "mutate"],
Some(&serde_json::to_value(body)?),
)
let value = request(&native, RuntimeOwnerRequest::HistoryMutate(body))
.await
.with_context(|| format!("canonical operation {operation} may have committed; retain this key, no automatic replay or replacement"))?;
let receipt: CanonicalThreadReceipt = serde_json::from_value(value).with_context(|| {
@@ -607,21 +631,21 @@ async fn mutate(
verify_receipt(&receipt, &owner, &operation).with_context(|| {
format!("canonical operation {operation} returned an unbound receipt; retain this key without replay or replacement")
})?;
present_receipt(&bridge, receipt, status, public_alias).await
present_receipt(&native, receipt, status, public_alias).await
}
async fn present_receipt(
bridge: &RuntimeBridge,
native: &Arc<dyn RuntimeOwnerFrontend>,
receipt: CanonicalThreadReceipt,
status: &str,
public_alias: Option<&str>,
) -> Result<ThreadResponse> {
let public_id = public_alias.unwrap_or(&receipt.runtime_thread_id);
let value = request(
bridge,
Method::GET,
&["v1", "threads", &receipt.runtime_thread_id],
None,
native,
RuntimeOwnerRequest::GetThread {
thread_id: receipt.runtime_thread_id.clone(),
},
)
.await
.map_err(|error| committed_failure(&receipt, error.context("metadata is unavailable")))?;
@@ -674,9 +698,7 @@ async fn recover_operation(
public_alias: Option<&str>,
) -> Result<Option<ThreadResponse>> {
let selected = owner(state)?;
let bridge = acquire_live_runtime_bridge(state)
.await
.map_err(|error| anyhow!("{}", error.message))?;
let native = owner_calls::captured(state)?;
let body = CanonicalThreadOperationLookup {
version: 1,
operation_key: operation.to_owned(),
@@ -685,8 +707,7 @@ async fn recover_operation(
workspace: workspace.to_owned(),
};
let outcome: CanonicalThreadOperationStatus = serde_json::from_value(request(
&bridge, Method::POST, &["v1","thread-history","operations","lookup"],
Some(&serde_json::to_value(&body)?),
&native, RuntimeOwnerRequest::HistoryLookup(body.clone()),
).await.with_context(|| format!("canonical key lookup {operation} is unavailable; retain key, no absence inference or replacement"))?)
.with_context(|| format!("canonical key lookup {operation} returned an invalid result; retain key, no absence inference or replacement"))?;
let (receipt, association, committed) = match outcome {
@@ -721,10 +742,7 @@ async fn recover_operation(
};
let recovered: CanonicalThreadOperationStatus = serde_json::from_value(
request(
&bridge,
Method::POST,
&["v1", "thread-history", "operations", "recover"],
Some(&serde_json::to_value(recovery)?),
&native, RuntimeOwnerRequest::HistoryRecover(recovery),
)
.await
.with_context(|| format!(
@@ -768,7 +786,7 @@ async fn recover_operation(
),
}
}
present_receipt(&bridge, receipt, status, public_alias)
present_receipt(&native, receipt, status, public_alias)
.await
.map(Some)
}
@@ -807,11 +825,8 @@ async fn resume_or_fork(
"moving a history source to another workspace requires the canonical owner's explicit admission"
);
}
let bridge = acquire_live_runtime_bridge(state)
.await
.map_err(|error| anyhow!("{}", error.message))?;
let snapshot = full_snapshot(state, &bridge, &id).await?;
drop(bridge);
let native = owner_calls::captured(state)?;
let snapshot = full_snapshot(state, &native, &id).await?;
let source = CanonicalHistorySource::Thread {
runtime_thread_id: id,
expected_document_digest: snapshot.document_digest,
@@ -1010,12 +1025,9 @@ async fn handle_owned(state: &AppState, request_value: ThreadRequest) -> Result<
Some(json!({"archived":false})),
),
ThreadRequest::GoalGet(p) => (p.thread_id, Method::GET, Some("goal"), None),
ThreadRequest::GoalSet(p) => (
p.thread_id,
Method::PUT,
Some("goal"),
Some(json!({"objective":p.objective,"token_budget":p.token_budget})),
),
ThreadRequest::GoalSet(_) => {
bail!("goal execution requires the canonical Runtime interaction API")
}
ThreadRequest::GoalClear(p) => (p.thread_id, Method::DELETE, Some("goal"), None),
ThreadRequest::GoalRecordProgress(_) => bail!(
"goal progress requires producing Engine usage/time receipts; client deltas are not accounting authority"
@@ -1030,25 +1042,41 @@ async fn handle_owned(state: &AppState, request_value: ThreadRequest) -> Result<
ThreadRequest::List(_) => unreachable!("list handled above"),
};
let (id, _) = resolve(state, &key, false).await?;
let bridge = acquire_live_runtime_bridge(state)
.await
.map_err(|e| anyhow!("{}", e.message))?;
let native = owner_calls::captured(state)?;
let mut result = response(key.clone());
let mut segments = vec!["v1", "threads", &id];
if let Some(action) = action {
segments.push(action)
}
let value = match request(&bridge, method.clone(), &segments, body.as_ref()).await {
let operation = match (method.as_str(), action) {
("GET", None) => RuntimeOwnerRequest::GetThread {
thread_id: id.clone(),
},
("PATCH", None) => RuntimeOwnerRequest::UpdateThread {
thread_id: id.clone(),
body: body.clone().context("missing update")?,
},
("GET", Some("goal")) => RuntimeOwnerRequest::GetGoal {
thread_id: id.clone(),
},
("DELETE", Some("goal")) => RuntimeOwnerRequest::ClearGoal {
thread_id: id.clone(),
},
_ => bail!("unsupported native thread operation"),
};
let value = match request(&native, operation).await {
Ok(value) => value,
Err(error)
if action == Some("goal")
&& (method == Method::GET || method == Method::DELETE)
&& error
.downcast_ref::<HttpFailure>()
.is_some_and(|e| e.status == StatusCode::NOT_FOUND) =>
.downcast_ref::<RuntimeOwnerFailure>()
.is_some_and(|e| e.status == StatusCode::NOT_FOUND.as_u16()) =>
{
// Distinguish an absent goal from a concurrently removed thread.
request(&bridge, Method::GET, &["v1", "threads", &id], None).await?;
request(
&native,
RuntimeOwnerRequest::GetThread {
thread_id: id.clone(),
},
)
.await?;
if method == Method::DELETE {
result.status = "empty".into();
}
@@ -1078,7 +1106,7 @@ async fn handle_owned(state: &AppState, request_value: ThreadRequest) -> Result<
"canonical control returned another target identity"
);
let mut thread = project_thread(record, &key)?;
if running_ids(&bridge).await?.contains(&id) && thread.status != ThreadStatus::Archived {
if running_ids(&native).await?.contains(&id) && thread.status != ThreadStatus::Archived {
thread.status = ThreadStatus::Running;
}
result.thread = Some(thread);
@@ -1090,7 +1118,7 @@ async fn handle_owned(state: &AppState, request_value: ThreadRequest) -> Result<
result.cwd = result.thread.as_ref().map(|t| t.cwd.clone());
result.data = value;
if method == Method::GET {
let snapshot = full_snapshot(state, &bridge, &id).await?;
let snapshot = full_snapshot(state, &native, &id).await?;
result.data["history"] = serde_json::to_value(snapshot)?;
}
}
+29 -18
View File
@@ -237,12 +237,12 @@ async fn owned_http(State(fake): State<FakeOwner>, request: Request) -> Response
.turn_seq
.fetch_add(1, std::sync::atomic::Ordering::SeqCst)
+ 1;
return Json(json!({"turn":{"id":format!("fixture-turn-{seq}")}})).into_response();
return Json(json!({"thread":{"id":"canonical-1","model":"fixture-model"},"turn":{"id":format!("fixture-turn-{seq}"),"thread_id":"canonical-1"}})).into_response();
}
if path == "/v1/threads/canonical-1/events" {
let seq = fake.turn_seq.load(std::sync::atomic::Ordering::SeqCst);
let frame = json!({"seq":seq,"turn_id":format!("fixture-turn-{seq}"),
"payload":{"turn":{"status":"completed"}}});
let frame = json!({"seq":seq,"thread_id":"canonical-1","turn_id":format!("fixture-turn-{seq}"),
"payload":{"turn":{"id":format!("fixture-turn-{seq}"),"thread_id":"canonical-1","status":"completed"}}});
return (
[(header::CONTENT_TYPE, "text/event-stream")],
format!("event: turn.completed\ndata: {frame}\n\n"),
@@ -357,7 +357,7 @@ async fn fixture(
FakeOwner,
tokio::task::JoinHandle<()>,
) {
let (state, temp, fake) = fixture_state(block_import);
let (mut state, temp, fake) = fixture_state(block_import);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let app = Router::new().fallback(owned_http).with_state(fake.clone());
@@ -367,6 +367,7 @@ async fn fixture(
let mut bridge = RuntimeBridge::from_base_url_for_test(format!("http://{address}"));
bridge.auth_token = Some("private-compatibility-reload-fixture".into());
*state.runtime_bridge.lock().await = Some(Arc::new(Mutex::new(bridge)));
crate::tests::install_mock_native_services(&mut state).await;
(state, temp, fake, server)
}
@@ -841,6 +842,7 @@ async fn creation_without_acknowledged_scope_or_stable_key_refuses_before_io() {
handle(&state, request)
.await
.unwrap_err()
.wire
.message
.contains("acknowledged workspace")
);
@@ -907,16 +909,17 @@ async fn confirmed_mutation_metadata_404_keeps_completed_effect_and_retry_identi
serde_json::from_value(json!({"kind":"start","operation_key":"completed-intent"})).unwrap();
let error = handle(&state, request).await.unwrap_err();
assert_eq!(fake.operations.lock().await.len(), 1);
assert!(error.message.contains("completed-intent"));
assert!(error.wire.message.contains("completed-intent"));
assert!(
error
.wire
.message
.contains("completed as thread canonical-1 / session session-1")
);
assert!(error.message.contains("without replay"));
assert!(error.wire.message.contains("without replay"));
assert_ne!(
error.code,
JsonRpcError::thread_not_found("canonical-1").code
error.wire.code,
DispatchError::thread_not_found("canonical-1").wire.code
);
server.abort();
}
@@ -931,6 +934,7 @@ async fn encoded_oversize_creation_refuses_before_owner_effect() {
let error = handle(&state, request).await.unwrap_err();
assert!(
error
.wire
.message
.contains("encoded canonical control exceeds bound")
);
@@ -1043,9 +1047,12 @@ async fn pending_retained_operation_never_reconstructs_or_replays_source() {
let count = fake.calls.lock().await.len();
let error = handle(&state, request).await.unwrap_err();
assert!(
error.message.contains("pending-recovery")
&& error.message.contains("pending")
&& error.message.contains("no resume, replay or replacement")
error.wire.message.contains("pending-recovery")
&& error.wire.message.contains("pending")
&& error
.wire
.message
.contains("no resume, replay or replacement")
);
let calls = fake.calls.lock().await;
assert_eq!(calls.len(), count + 2);
@@ -1121,8 +1128,8 @@ async fn pending_wrong_action_refuses_before_any_prepared_recovery() {
.unwrap();
let error = handle(&state, request).await.unwrap_err();
assert!(
error.message.contains("another action or source")
&& error.message.contains("wrong-pending-action")
error.wire.message.contains("another action or source")
&& error.wire.message.contains("wrong-pending-action")
);
let calls = fake.calls.lock().await;
assert_eq!(calls.len(), count + 1);
@@ -1149,9 +1156,12 @@ async fn changed_prepared_target_refusal_keeps_reserved_operation_and_no_replay(
let count = fake.calls.lock().await.len();
let error = handle(&state, request).await.unwrap_err();
assert!(
error.message.contains("changed-prepared-target")
&& error.message.contains("canonical-1 / session session-1")
&& error.message.contains("prepared target graph changed")
error.wire.message.contains("changed-prepared-target")
&& error
.wire
.message
.contains("canonical-1 / session session-1")
&& error.wire.message.contains("prepared target graph changed")
);
let calls = fake.calls.lock().await;
assert_eq!(calls.len(), count + 2);
@@ -1176,10 +1186,11 @@ async fn retained_operation_rejects_another_action_or_source_with_completed_fact
let error = handle(&state, request).await.unwrap_err();
assert!(
error
.wire
.message
.contains("completed as thread canonical-1 / session session-1")
);
assert!(error.message.contains("another action or source"));
assert!(error.wire.message.contains("another action or source"));
assert_eq!(fake.operations.lock().await.len(), 1);
server.abort();
}
@@ -1381,7 +1392,7 @@ async fn conflicting_existing_owner_goal_is_visible_refusal_without_empty_alias(
thread_id: "legacy".into(),
});
let error = handle(&state, request).await.unwrap_err();
assert!(error.message.contains("canonical goal conflict"));
assert!(error.wire.message.contains("canonical goal conflict"));
assert!(
store
.get_canonical_runtime_link("legacy", state.captured_owner.as_ref().unwrap())
+357 -114
View File
@@ -1,38 +1,44 @@
use crate::bridge::acquire_live_runtime_bridge;
use crate::stdio::{emit_stdio_event, interrupt_turn_request, retire_turn_registration};
use crate::{
AppState, JsonRpcError, RuntimeThreadHint, TurnTerminalStatus, TurnTranscript, thread_control,
AppState, DispatchError, InFlightTurn, RuntimeOwnerFrontend, RuntimeOwnerRequest,
RuntimeThreadHint, TurnExecutionOwner, TurnRegistry, TurnTerminalStatus, TurnTranscript,
owner_calls, thread_control,
};
use anyhow::{Result, anyhow};
use anyhow::{Context as _, Result, anyhow};
use codewhale_app_server_protocol::runtime::RuntimeImageInput;
use codewhale_protocol::{PromptRequest, PromptResponse, ThreadRequest, ThreadResponse};
use codewhale_protocol::{
EventFrame, PromptRequest, PromptResponse, ResponseChannel, ThreadRequest, ThreadResponse,
};
use futures_util::StreamExt;
use serde_json::{Value, json};
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
use tokio::io::AsyncWrite;
use tokio_util::sync::CancellationToken;
use uuid::Uuid;
pub(crate) async fn handle_thread_request(
state: &AppState,
req: ThreadRequest,
) -> std::result::Result<ThreadResponse, JsonRpcError> {
) -> std::result::Result<ThreadResponse, DispatchError> {
if matches!(&req, ThreadRequest::GoalSet(_)) {
return Err(JsonRpcError::unsupported_interactive_execution());
return Err(DispatchError::unsupported_interactive_execution());
}
thread_control::handle(state, req).await
}
/// One turn's worth of routing decisions, shared by every surface that runs
/// a turn through the bridge.
pub(crate) struct RuntimeTurnInput<'a> {
pub(crate) input: &'a str,
pub(crate) images: &'a [RuntimeImageInput],
pub(crate) max_output_tokens: Option<std::num::NonZeroU32>,
pub(crate) expected_workspace: Option<&'a Path>,
pub(crate) cancellation: Option<&'a CancellationToken>,
}
pub(crate) struct BridgedTurn<'a> {
pub(crate) struct NativeTurn<'a> {
pub(crate) allowed_tools: Option<&'a [String]>,
pub(crate) max_output_tokens: Option<std::num::NonZeroU32>,
/// Client-facing thread id; the bridge maps it to a runtime thread.
pub(crate) thread_key: &'a str,
pub(crate) input: &'a str,
pub(crate) images: &'a [RuntimeImageInput],
@@ -42,30 +48,20 @@ pub(crate) struct BridgedTurn<'a> {
/// Publish the live turn so a concurrent `thread/interrupt` can cancel
/// it. Only stdio has a mid-turn channel, so only stdio sets this.
pub(crate) interruptible: bool,
/// Forget the thread mapping once the turn ends. Set for one-shot
/// prompts, whose synthetic thread key no client can name again.
pub(crate) ephemeral: bool,
/// Refuse a `thread_key` that is neither mapped, durably linked, nor a
/// persisted thread, instead of minting an empty runtime thread for it.
/// Set by `thread/message`, whose ids come from `thread/create`.
pub(crate) require_known_thread: bool,
pub(crate) cancellation: Option<CancellationToken>,
}
/// Execute exactly one turn on the real runtime.
///
/// This is the only way any app-server surface runs a model: `/prompt`,
/// `prompt/request`, `prompt/run`, stdio `thread/message`, and HTTP `/thread`
/// messages all land here. There is no local fallback that fabricates a
/// response — if the runtime cannot be reached the caller gets
/// [`JsonRpcError::runtime_unavailable`] and nothing is written to history.
pub(crate) async fn run_bridged_turn<W: AsyncWrite + Unpin>(
pub(crate) async fn run_native_turn<W: AsyncWrite + Unpin>(
state: &AppState,
writer: &mut W,
turn: BridgedTurn<'_>,
turn: NativeTurn<'_>,
transcript: Option<&mut TurnTranscript>,
) -> std::result::Result<Value, JsonRpcError> {
) -> std::result::Result<Value, DispatchError> {
if !matches!(turn.allowed_tools, Some([])) {
return Err(JsonRpcError::unsupported_interactive_execution());
return Err(DispatchError::unsupported_interactive_execution());
}
let mut hint = {
let hints = state.stdio_thread_hints.lock().await;
@@ -85,9 +81,6 @@ pub(crate) async fn run_bridged_turn<W: AsyncWrite + Unpin>(
.map_err(|error| thread_control::rpc_error(error, Some(turn.thread_key)))?,
)
};
if turn.require_known_thread && resolved.is_none() {
return Err(JsonRpcError::thread_not_found(turn.thread_key));
}
if let Some((_, workspace)) = resolved.as_ref() {
hint.get_or_insert_with(RuntimeThreadHint::default)
.workspace = Some(workspace.clone());
@@ -99,71 +92,91 @@ pub(crate) async fn run_bridged_turn<W: AsyncWrite + Unpin>(
// Retain the acknowledged/observed scope across all transport awaits.
// The owning Runtime rechecks this assertion at its final turn admission.
let expected_workspace = hint.as_ref().and_then(|hint| hint.workspace.clone());
let mut bridge = acquire_live_runtime_bridge(state).await?;
let mut thread_map = state.runtime_thread_map.clone().lock_owned().await;
if let Some((id, _)) = resolved {
thread_map.insert(turn.thread_key.to_string(), id);
}
let owner = owner_calls::captured(state)
.map_err(|error| DispatchError::runtime_unavailable(error.to_string()))?;
if turn.max_output_tokens.is_some() {
let info = bridge
.request_json(
bridge.authed(
bridge
.client
.get(format!("{}/v1/runtime/info", bridge.base_url)),
),
)
let info = owner_calls::execute(&owner, RuntimeOwnerRequest::Capabilities)
.await
.map_err(|error| JsonRpcError::runtime_unavailable(error.to_string()))?;
.map_err(|error| DispatchError::runtime_unavailable(error.to_string()))?;
if info
.pointer("/capabilities/turn_output_token_limit")
.and_then(Value::as_bool)
!= Some(true)
{
return Err(JsonRpcError::invalid_params(
return Err(DispatchError::invalid_params(
"Runtime does not support maxOutputTokens",
));
}
if !thread_map.contains_key(turn.thread_key) {
bridge
.require_output_limited_model(hint.as_ref().and_then(|hint| hint.model.as_deref()))
.await
.map_err(|error| JsonRpcError::invalid_params(error.to_string()))?;
}
}
let runtime_thread_id = bridge
.ensure_runtime_thread(&mut thread_map, turn.thread_key, hint)
if turn.max_output_tokens.is_some() && resolved.is_none() {
owner_calls::require_output_limited_model(
&owner,
hint.as_ref().and_then(|hint| hint.model.as_deref()),
)
.await
.map_err(|error| JsonRpcError::runtime_unavailable(error.to_string()))?;
drop(thread_map);
let registration = turn
.interruptible
.then(|| (state.in_flight_turns.clone(), turn.thread_key.to_string()));
let result = bridge
.message_thread(
.map_err(|error| DispatchError::invalid_params(error.to_string()))?;
}
let runtime_thread_id = match resolved {
Some((id, _)) => id,
None => {
let hint = hint.unwrap_or_default();
let record = owner_calls::execute(
&owner,
RuntimeOwnerRequest::CreateThread(json!({
"model": hint.model,
"workspace": hint.workspace,
"mode": "agent",
"archived": false,
})),
)
.await
.map_err(|error| DispatchError::runtime_unavailable(format!("{error:#}")))?;
extract_runtime_thread_id(&record)
.map_err(|error| DispatchError::internal(format!("{error:#}")))?
.to_owned()
}
};
let result = async {
let registration = turn
.interruptible
.then(|| (state.in_flight_turns.clone(), turn.thread_key.to_owned()));
message_native_thread(
owner,
&runtime_thread_id,
RuntimeTurnInput {
input: turn.input,
images: turn.images,
max_output_tokens: turn.max_output_tokens,
expected_workspace: expected_workspace.as_deref(),
cancellation: turn.cancellation.as_ref(),
},
writer,
registration,
transcript,
)
.await;
if turn.ephemeral {
// Drop the mapping while we still hold the bridge lock, so a
// long-lived app-server does not accumulate one entry per one-shot
// prompt.
let mut thread_map = state.runtime_thread_map.lock().await;
bridge.forget_thread(&mut thread_map, turn.thread_key);
.await
.map_err(|error| {
if error.downcast_ref::<NativeTurnContext>().is_some() {
error
} else {
error.context(NativeTurnContext {
thread_id: runtime_thread_id,
turn_id: None,
})
}
})
}
result.map_err(|err| {
let mut response = JsonRpcError::internal(err.to_string());
if crate::stdio::is_control_write_failure(&err) {
response.transport_error = Some(err);
.await;
result.map_err(|error| {
let mut response = DispatchError::internal(format!("{error:#}"));
if let Some(context) = error.downcast_ref::<NativeTurnContext>() {
response.wire.data = Some(json!({
"thread_id": context.thread_id, "turn_id": context.turn_id,
"replay_safe": false,
}));
}
if crate::stdio::is_control_write_failure(&error) {
response.transport_error = Some(error);
}
response
})
@@ -179,13 +192,11 @@ pub(crate) async fn run_prompt_turn<W: AsyncWrite + Unpin>(
state: &AppState,
writer: &mut W,
req: PromptRequest,
) -> std::result::Result<PromptResponse, JsonRpcError> {
cancellation: Option<CancellationToken>,
) -> std::result::Result<PromptResponse, DispatchError> {
if req.prompt.trim().is_empty() {
return Err(JsonRpcError::invalid_params("prompt must not be empty"));
return Err(DispatchError::invalid_params("prompt must not be empty"));
}
// The turn engine has no threadless mode, so a prompt without a thread
// gets a fresh one. Keying it on a uuid keeps a one-shot prompt out of
// any caller's history and out of the way of concurrent prompts.
let ephemeral = req.thread_id.is_none();
let thread_key = req
.thread_id
@@ -193,10 +204,10 @@ pub(crate) async fn run_prompt_turn<W: AsyncWrite + Unpin>(
.unwrap_or_else(|| format!("prompt-{}", Uuid::new_v4()));
let mut transcript = TurnTranscript::default();
run_bridged_turn(
run_native_turn(
state,
writer,
BridgedTurn {
NativeTurn {
allowed_tools: req.allowed_tools.as_deref(),
max_output_tokens: req.max_output_tokens,
thread_key: &thread_key,
@@ -204,33 +215,16 @@ pub(crate) async fn run_prompt_turn<W: AsyncWrite + Unpin>(
images: &req.images,
model_override: req.model.clone(),
// `thread/interrupt` addresses client-facing thread ids. A
// one-shot prompt has none to hand back, and a caller-supplied
// thread id is already interruptible through `thread/message`.
// one-shot prompt has none to hand back.
interruptible: false,
ephemeral,
// A caller-chosen `/prompt` thread key keeps its conversation
// but need not name a `thread/create` thread.
require_known_thread: false,
cancellation,
},
Some(&mut transcript),
)
.await?;
// Report the model the runtime actually ran, never a locally resolved
// guess. The fallbacks only matter for a runtime that omits the field.
let model = match transcript.model {
Some(model) => model,
None => match req.model {
Some(model) => model,
None => state
.config
.read()
.await
.model
.clone()
.unwrap_or_else(|| "unknown".to_string()),
},
};
let model = transcript.model.unwrap_or_else(|| "unknown".to_string());
Ok(PromptResponse {
output: transcript.text,
@@ -243,8 +237,9 @@ pub(crate) async fn handle_prompt_request<W: AsyncWrite + Unpin>(
state: &AppState,
writer: &mut W,
req: PromptRequest,
) -> std::result::Result<PromptResponse, JsonRpcError> {
run_prompt_turn(state, writer, req).await
cancellation: Option<CancellationToken>,
) -> std::result::Result<PromptResponse, DispatchError> {
run_prompt_turn(state, writer, req, cancellation).await
}
/// HTTP `/thread` with a `Message` body: same engine as stdio
@@ -257,13 +252,13 @@ pub(crate) async fn run_http_thread_message(
allowed_tools: Option<Vec<String>>,
images: Vec<RuntimeImageInput>,
max_output_tokens: Option<std::num::NonZeroU32>,
) -> std::result::Result<ThreadResponse, JsonRpcError> {
) -> std::result::Result<ThreadResponse, DispatchError> {
let mut transcript = TurnTranscript::default();
let mut sink = tokio::io::sink();
let result = run_bridged_turn(
let result = run_native_turn(
state,
&mut sink,
BridgedTurn {
NativeTurn {
allowed_tools: allowed_tools.as_deref(),
max_output_tokens,
thread_key: &thread_id,
@@ -272,7 +267,7 @@ pub(crate) async fn run_http_thread_message(
model_override: None,
interruptible: false,
ephemeral: false,
require_known_thread: true,
cancellation: None,
},
Some(&mut transcript),
)
@@ -303,17 +298,265 @@ pub(crate) fn extract_runtime_thread_id(record: &Value) -> Result<&str> {
.ok_or_else(|| anyhow!("runtime API thread response missing id"))
}
pub(crate) fn turn_terminal_status(payload: &Value) -> TurnTerminalStatus {
match payload
.pointer("/turn/status")
.and_then(Value::as_str)
.unwrap_or("completed")
.to_ascii_lowercase()
.as_str()
{
"failed" => TurnTerminalStatus::Failed,
"interrupted" => TurnTerminalStatus::Interrupted,
"canceled" | "cancelled" => TurnTerminalStatus::Canceled,
_ => TurnTerminalStatus::Completed,
pub(crate) fn turn_terminal_status(payload: &Value) -> Result<TurnTerminalStatus> {
match payload.pointer("/turn/status").and_then(Value::as_str) {
Some("completed") => Ok(TurnTerminalStatus::Completed),
Some("failed") => Ok(TurnTerminalStatus::Failed),
Some("interrupted") => Ok(TurnTerminalStatus::Interrupted),
Some("canceled" | "cancelled") => Ok(TurnTerminalStatus::Canceled),
_ => anyhow::bail!("native terminal event has no supported terminal status"),
}
}
#[derive(Debug, Clone, thiserror::Error)]
#[error(
"native thread {thread_id}, turn {turn_id:?}; inspect retained native state without replay"
)]
struct NativeTurnContext {
thread_id: String,
turn_id: Option<String>,
}
struct NativeTurnClaim {
live: InFlightTurn,
registration: Option<(TurnRegistry, String)>,
registration_published: bool,
terminal_observed: bool,
cleanup: Option<tokio::task::JoinHandle<Result<()>>>,
}
impl NativeTurnClaim {
fn begin_cleanup(&mut self) -> Result<()> {
if self.cleanup.is_some() {
return Ok(());
}
let handle = tokio::runtime::Handle::try_current()
.context("native turn cleanup has no active runtime; inspect without replay")?;
let live = self.live.clone();
let registration = self.registration.clone();
let registration_published = self.registration_published;
let terminal_observed = self.terminal_observed;
self.cleanup = Some(handle.spawn(async move {
let interrupted = if terminal_observed {
Ok(())
} else {
interrupt_turn_request(&live)
.await
.map(|_| ())
.map_err(|error| anyhow!("native interruption remains uncertain: {}", error.wire.message))
};
if let Some((registry, key)) = registration {
if interrupted.is_ok() {
retire_turn_registration(&registry, &key, &live).await;
} else if !registration_published {
registry.lock().await.entry(key).or_insert_with(|| live.clone());
}
}
if let Err(error) = &interrupted {
tracing::error!(%error, thread_id = %live.runtime_thread_id, turn_id = %live.turn_id, "native turn transport cleanup did not confirm interruption");
}
interrupted
}));
Ok(())
}
async fn finish(&mut self) -> Result<()> {
self.begin_cleanup()?;
self.cleanup
.as_mut()
.expect("started cleanup")
.await
.context("native cleanup task failed; inspect its canonical receipt without replay")?
}
}
impl Drop for NativeTurnClaim {
fn drop(&mut self) {
if let Err(error) = self.begin_cleanup() {
tracing::error!(%error, thread_id = %self.live.runtime_thread_id, turn_id = %self.live.turn_id, "native turn transport cleanup could not be scheduled");
}
}
}
async fn message_native_thread<W: AsyncWrite + Unpin>(
owner: Arc<dyn RuntimeOwnerFrontend>,
thread_id: &str,
input: RuntimeTurnInput<'_>,
writer: &mut W,
registration: Option<(TurnRegistry, String)>,
mut transcript: Option<&mut TurnTranscript>,
) -> Result<Value> {
anyhow::ensure!(
!input
.cancellation
.is_some_and(CancellationToken::is_cancelled),
"control request was canceled before native turn admission"
);
let mut events = tokio::time::timeout(
Duration::from_secs(30),
owner.events(thread_id.to_owned(), 0),
)
.await
.context("native event observation did not open before admission")??;
anyhow::ensure!(
!input
.cancellation
.is_some_and(CancellationToken::is_cancelled),
"control request was canceled before native turn admission"
);
let mut body = json!({ "prompt": input.input, "allowed_tools": [] });
if let Some(workspace) = input.expected_workspace {
body["expected_workspace"] = json!(workspace);
}
if !input.images.is_empty() {
body["images"] = json!(input.images);
}
if let Some(limit) = input.max_output_tokens {
body["maxOutputTokens"] = json!(limit);
}
let accepted = owner_calls::execute(
&owner,
RuntimeOwnerRequest::StartTurn {
thread_id: thread_id.to_owned(),
body,
},
)
.await?;
let turn_id = accepted
.pointer("/turn/id")
.and_then(Value::as_str)
.context("native owner admission returned no turn identity")?
.to_owned();
anyhow::ensure!(
accepted.pointer("/turn/thread_id").and_then(Value::as_str) == Some(thread_id),
"native owner admission returned another thread identity; no automatic replay"
);
let response_id = format!("{thread_id}:{turn_id}");
let live = InFlightTurn {
owner: TurnExecutionOwner::Native(owner),
runtime_thread_id: thread_id.to_owned(),
turn_id: turn_id.clone(),
};
let mut claim = NativeTurnClaim {
live,
registration,
registration_published: false,
terminal_observed: false,
cleanup: None,
};
let context = NativeTurnContext {
thread_id: thread_id.to_owned(),
turn_id: Some(turn_id.clone()),
};
if let Some(transcript) = transcript.as_deref_mut() {
transcript.model = accepted
.pointer("/thread/model")
.and_then(Value::as_str)
.map(str::to_owned);
transcript.events.push(EventFrame::ResponseStart {
response_id: response_id.clone(),
});
}
let result = async {
if let Some((registry, key)) = claim.registration.as_ref() {
registry.lock().await.insert(key.clone(), claim.live.clone());
claim.registration_published = true;
}
anyhow::ensure!(
!input.cancellation.is_some_and(CancellationToken::is_cancelled),
"control request was canceled after native turn admission"
);
emit_stdio_event(writer, json!({
"type": "response_start", "response_id": response_id,
})).await?;
loop {
let frame = tokio::select! {
_ = async {
match input.cancellation {
Some(cancellation) => cancellation.cancelled().await,
None => std::future::pending::<()>().await,
}
} => anyhow::bail!("control request canceled; native turn cleanup required"),
frame = events.next() => frame,
};
let Some(frame) = frame else { break; };
if frame.event == "stream.end" {
anyhow::bail!("native journal observation ended before terminal settlement; no automatic replay");
}
if frame.data.get("thread_id").and_then(Value::as_str) != Some(thread_id)
|| frame.data.get("turn_id").and_then(Value::as_str) != Some(turn_id.as_str()) {
continue;
}
let payload = frame.data.get("payload").cloned().unwrap_or(Value::Null);
match frame.event.as_str() {
"approval.required" | "user_input.required" | "tool_call.requested" => {
anyhow::bail!("interactive_execution_unsupported: native owner requested interaction during a tool-free turn");
}
"item.delta" if payload.get("kind").and_then(Value::as_str) == Some("agent_message") => {
if let Some(delta) = payload.get("delta").and_then(Value::as_str).filter(|delta| !delta.is_empty()) {
emit_stdio_event(writer, json!({
"type": "response_delta", "response_id": response_id, "delta": delta,
})).await?;
if let Some(transcript) = transcript.as_deref_mut() {
transcript.text.push_str(delta);
transcript.events.push(EventFrame::ResponseDelta {
response_id: response_id.clone(), delta: delta.to_owned(),
channel: ResponseChannel::Text,
});
}
}
}
"turn.completed" => {
let record = payload.get("turn").context("native terminal event omitted its turn")?;
anyhow::ensure!(
record.get("id").and_then(Value::as_str) == Some(turn_id.as_str())
&& record.get("thread_id").and_then(Value::as_str) == Some(thread_id),
"native terminal event has another turn identity"
);
let status = turn_terminal_status(&payload)?;
claim.terminal_observed = true;
if let Some(transcript) = transcript.as_deref_mut() {
if let Some(model) = record.get("effective_model").and_then(Value::as_str) {
transcript.model = Some(model.to_owned());
}
}
match status {
TurnTerminalStatus::Completed => return Ok(record.clone()),
_ => anyhow::bail!("{}", record.get("error").and_then(Value::as_str).unwrap_or("native turn stopped without successful completion")),
}
}
_ => {}
}
}
anyhow::bail!("native event stream ended before turn.completed; no automatic replay")
}.await;
let cleanup = claim.finish().await;
let record = match (result, cleanup) {
(Ok(record), Ok(())) => record,
(Err(error), Ok(())) => return Err(error.context(context)),
(Ok(_), Err(error)) => return Err(error.context(context)),
(Err(error), Err(cleanup)) => {
return Err(error
.context(format!(
"native cleanup did not confirm interruption: {cleanup:#}"
))
.context(context));
}
};
emit_stdio_event(
writer,
json!({ "type": "response_end", "response_id": response_id }),
)
.await
.context(context)?;
if let Some(transcript) = transcript {
transcript
.events
.push(EventFrame::ResponseEnd { response_id });
}
Ok(json!({
"thread_id": thread_id, "status": "accepted", "thread": null, "threads": [],
"model": null, "model_provider": null, "cwd": null, "approval_policy": null,
"sandbox": null, "events": [], "data": { "turn_id": turn_id, "turn": record },
}))
}
+235
View File
@@ -0,0 +1,235 @@
use crate::stdio::serialize_control_frame;
use codewhale_app_server_protocol::runtime::MAX_RUNTIME_IMAGE_BODY_BYTES;
use futures_util::{Sink, Stream};
use std::io;
use std::pin::Pin;
use std::task::{Context, Poll, ready};
use std::time::Duration;
use tokio::io::{AsyncRead, AsyncReadExt as _, AsyncWrite, Join, ReadBuf, Take};
use tokio_tungstenite::WebSocketStream;
use tokio_tungstenite::tungstenite::handshake::server::{Request, Response};
use tokio_tungstenite::tungstenite::protocol::{Message, WebSocketConfig};
pub(crate) type LocalWebSocket<R, W> = WebSocketLines<Join<Take<R>, W>>;
fn configuration() -> WebSocketConfig {
WebSocketConfig::default()
.write_buffer_size(0)
.max_write_buffer_size(MAX_RUNTIME_IMAGE_BODY_BYTES + 128)
.max_message_size(Some(MAX_RUNTIME_IMAGE_BODY_BYTES))
.max_frame_size(Some(MAX_RUNTIME_IMAGE_BODY_BYTES))
}
pub(crate) async fn accept<R, W>(read: R, write: W) -> anyhow::Result<LocalWebSocket<R, W>>
where
R: AsyncRead + Unpin,
W: AsyncWrite + Unpin,
{
let stream = tokio::io::join(read.take(16384), write);
let mut socket = tokio::time::timeout(
Duration::from_secs(5),
tokio_tungstenite::accept_hdr_async_with_config(
stream,
|request: &Request, response: Response| {
if request.headers().contains_key("origin") {
let mut refusal = tokio_tungstenite::tungstenite::http::Response::new(Some(
"Origin is not accepted on the private owner channel".to_string(),
));
*refusal.status_mut() =
tokio_tungstenite::tungstenite::http::StatusCode::FORBIDDEN;
return Err(refusal);
}
Ok(response)
},
Some(configuration()),
),
)
.await??;
socket.get_mut().reader_mut().set_limit(u64::MAX);
Ok(WebSocketLines::new(socket))
}
pub(crate) async fn connect<R, W>(read: R, write: W) -> anyhow::Result<LocalWebSocket<R, W>>
where
R: AsyncRead + Unpin,
W: AsyncWrite + Unpin,
{
let stream = tokio::io::join(read.take(16384), write);
let (mut socket, _) = tokio::time::timeout(
Duration::from_secs(5),
tokio_tungstenite::client_async_with_config(
"ws://localhost/",
stream,
Some(configuration()),
),
)
.await??;
socket.get_mut().reader_mut().set_limit(u64::MAX);
Ok(WebSocketLines::new(socket))
}
fn peer_gone(error: &tokio_tungstenite::tungstenite::Error) -> bool {
use tokio_tungstenite::tungstenite::Error;
match error {
Error::ConnectionClosed | Error::AlreadyClosed => true,
Error::Io(error) => matches!(
error.kind(),
io::ErrorKind::BrokenPipe
| io::ErrorKind::ConnectionReset
| io::ErrorKind::ConnectionAborted
| io::ErrorKind::UnexpectedEof
),
_ => false,
}
}
pub(crate) struct WebSocketLines<S> {
socket: WebSocketStream<S>,
incoming: Vec<u8>,
incoming_offset: usize,
outgoing: Vec<u8>,
flush_control: bool,
closed: bool,
}
impl<S> WebSocketLines<S> {
fn new(socket: WebSocketStream<S>) -> Self {
Self {
socket,
incoming: Vec::new(),
incoming_offset: 0,
outgoing: Vec::new(),
flush_control: false,
closed: false,
}
}
}
impl<S: AsyncRead + AsyncWrite + Unpin> AsyncRead for WebSocketLines<S> {
fn poll_read(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
buffer: &mut ReadBuf<'_>,
) -> Poll<io::Result<()>> {
let this = self.get_mut();
if buffer.remaining() == 0 {
return Poll::Ready(Ok(()));
}
for _ in 0..32 {
if this.incoming_offset < this.incoming.len() {
let count = buffer
.remaining()
.min(this.incoming.len() - this.incoming_offset);
buffer
.put_slice(&this.incoming[this.incoming_offset..this.incoming_offset + count]);
this.incoming_offset += count;
if this.incoming_offset == this.incoming.len() {
this.incoming.clear();
this.incoming_offset = 0;
}
return Poll::Ready(Ok(()));
}
if this.flush_control {
match ready!(Pin::new(&mut this.socket).poll_flush(cx)) {
Ok(()) => this.flush_control = false,
Err(error) if peer_gone(&error) => {
this.closed = true;
this.flush_control = false;
return Poll::Ready(Ok(()));
}
Err(error) => return Poll::Ready(Err(io::Error::other(error))),
}
}
if this.closed {
return Poll::Ready(Ok(()));
}
match ready!(Pin::new(&mut this.socket).poll_next(cx)) {
Some(Ok(Message::Text(text))) => {
this.incoming = match serde_json::from_str::<serde_json::Value>(&text) {
Ok(value) => serialize_control_frame(&value)?,
Err(_) if !text.contains(['\r', '\n']) => text.as_bytes().to_vec(),
Err(_) => b"{invalid WebSocket JSON".to_vec(),
};
this.incoming.reserve_exact(1);
this.incoming.push(b'\n');
}
Some(Ok(Message::Ping(_))) => this.flush_control = true,
Some(Ok(Message::Close(_))) => {
this.closed = true;
this.flush_control = true;
}
Some(Ok(Message::Binary(_) | Message::Pong(_) | Message::Frame(_))) => {}
Some(Err(tokio_tungstenite::tungstenite::Error::Protocol(
tokio_tungstenite::tungstenite::error::ProtocolError::ResetWithoutClosingHandshake,
))) => {
this.closed = true;
return Poll::Ready(Ok(()));
}
Some(Err(error)) if peer_gone(&error) => {
this.closed = true;
return Poll::Ready(Ok(()));
}
Some(Err(error)) => return Poll::Ready(Err(io::Error::other(error))),
None => {
this.closed = true;
return Poll::Ready(Ok(()));
}
}
}
cx.waker().wake_by_ref();
Poll::Pending
}
}
impl<S: AsyncRead + AsyncWrite + Unpin> AsyncWrite for WebSocketLines<S> {
fn poll_write(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
bytes: &[u8],
) -> Poll<io::Result<usize>> {
let this = self.get_mut();
if this.closed {
return Poll::Ready(Err(io::ErrorKind::BrokenPipe.into()));
}
let newline = bytes.iter().position(|byte| *byte == b'\n');
let count = newline.unwrap_or(bytes.len());
if count > MAX_RUNTIME_IMAGE_BODY_BYTES.saturating_sub(this.outgoing.len()) {
return Poll::Ready(Err(io::Error::new(
io::ErrorKind::InvalidData,
crate::stdio::FrameRefusal::TooLarge,
)));
}
if newline.is_some() {
ready!(Pin::new(&mut this.socket).poll_ready(cx)).map_err(io::Error::other)?;
}
this.outgoing.reserve_exact(count);
this.outgoing.extend_from_slice(&bytes[..count]);
if newline.is_some() {
let text = String::from_utf8(std::mem::take(&mut this.outgoing))
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
Pin::new(&mut this.socket)
.start_send(Message::Text(text.into()))
.map_err(io::Error::other)?;
}
Poll::Ready(Ok(count + usize::from(newline.is_some())))
}
fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
Pin::new(&mut self.get_mut().socket)
.poll_flush(cx)
.map_err(io::Error::other)
}
fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
let this = self.get_mut();
if !this.outgoing.is_empty() {
return Poll::Ready(Err(io::Error::new(
io::ErrorKind::InvalidData,
"private WebSocket output ended inside a JSON frame",
)));
}
Pin::new(&mut this.socket)
.poll_close(cx)
.map_err(io::Error::other)
}
}
+181 -108
View File
@@ -13,11 +13,11 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH};
use codewhale_app_server::daemon_socket::{
DaemonSocketError, DaemonSocketOptions, bind_daemon_socket,
};
use futures_util::{SinkExt as _, StreamExt as _};
use serde_json::{Value, json};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::UnixStream;
use tokio::net::unix::{OwnedReadHalf, OwnedWriteHalf};
use tokio::task::JoinHandle;
use tokio_tungstenite::{WebSocketStream, tungstenite::Message};
static NONCE: AtomicU64 = AtomicU64::new(0);
@@ -357,8 +357,7 @@ async fn owner_bootstrap_fresh_bind_cannot_retire_uncaptured_publication() {
}
struct Client {
reader: BufReader<OwnedReadHalf>,
writer: OwnedWriteHalf,
socket: WebSocketStream<UnixStream>,
}
impl Client {
@@ -367,59 +366,88 @@ impl Client {
.await
.expect("connect timeout")
.expect("connect");
let (rx, writer) = stream.into_split();
Self {
reader: BufReader::new(rx),
writer,
}
let (socket, _) = tokio::time::timeout(
Duration::from_secs(5),
tokio_tungstenite::client_async("ws://localhost/", stream),
)
.await
.expect("WebSocket upgrade timeout")
.expect("WebSocket upgrade");
Self { socket }
}
async fn call(&mut self, id: u64, method: &str, params: Value) -> Value {
let line = serde_json::to_string(&json!({
"jsonrpc": "2.0",
let frame = json!({
"id": id,
"method": method,
"params": params,
}))
.expect("encode");
self.writer
.write_all(format!("{line}\n").as_bytes())
});
self.socket
.send(Message::Text(frame.to_string().into()))
.await
.expect("write");
let mut response = String::new();
let read = tokio::time::timeout(
Duration::from_secs(10),
self.reader.read_line(&mut response),
)
.await
.expect("response timeout")
.expect("read");
assert!(
read > 0,
"daemon closed the connection before answering `{method}`"
);
.expect("write text frame");
let response = tokio::time::timeout(Duration::from_secs(10), self.socket.next())
.await
.expect("response timeout")
.expect("daemon closed before response")
.expect("read text frame");
let Message::Text(response) = response else {
panic!("expected one JSON text frame, got {response:?}");
};
let value: Value = serde_json::from_str(&response).expect("json response");
assert_eq!(value["id"], json!(id), "response id mismatch: {value}");
assert!(value.get("jsonrpc").is_none());
value
}
async fn attach(&mut self, id: u64, name: &str, mode: &str) -> Value {
self.call(
let response = self.call(
id,
"daemon/attach",
json!({ "client": { "name": name, "version": "0.0.0-test", "pid": std::process::id() }, "mode": mode }),
)
.await
.await;
if response.get("error").is_none() {
self.initialize().await;
}
response
}
/// Read until EOF; proves the daemon closed the socket.
async fn wait_for_close(mut self) {
let mut sink = String::new();
let read = tokio::time::timeout(Duration::from_secs(10), self.reader.read_line(&mut sink))
async fn initialize(&mut self) {
let initialized = self
.call(
0,
"initialize",
json!({"clientInfo":{"name":"daemon-fixture","version":"1"}}),
)
.await;
assert!(initialized.get("error").is_none(), "{initialized}");
self.socket
.send(Message::Text("{\"method\":\"initialized\"}".into()))
.await
.expect("close timeout")
.expect("read");
assert_eq!(read, 0, "expected EOF, got: {sink}");
.expect("initialized notification");
}
async fn wait_for_close(mut self) {
let response = tokio::time::timeout(Duration::from_secs(10), self.socket.next())
.await
.expect("close timeout");
match response {
None | Some(Ok(Message::Close(_))) => {}
Some(Err(tokio_tungstenite::tungstenite::Error::Protocol(
tokio_tungstenite::tungstenite::error::ProtocolError::ResetWithoutClosingHandshake,
))) => {}
Some(Err(tokio_tungstenite::tungstenite::Error::Io(error))) => assert!(
matches!(
error.kind(),
std::io::ErrorKind::UnexpectedEof
| std::io::ErrorKind::ConnectionReset
| std::io::ErrorKind::BrokenPipe
),
"unexpected transport error: {error}"
),
other => panic!("expected transport closure, got {other:?}"),
}
}
}
@@ -803,6 +831,7 @@ async fn captured_owner_frontend_authenticates_generation_without_publishing_bea
.await;
assert_eq!(attached["result"]["role"], "attached");
assert_eq!(attached["result"]["owner_receipt"], json!(owner));
guest.initialize().await;
// The display PID is deliberately false: authorization uses kernel peer credentials.
let denied = guest.call(5, "shutdown", json!({})).await;
assert!(denied["error"].is_object());
@@ -820,33 +849,15 @@ async fn captured_owner_frontend_authenticates_generation_without_publishing_bea
);
}
/// Native IPC/HTTP transport proof with a fake owned Runtime; no Engine or
/// Native IPC transport proof with a fake owned Runtime; no Engine or
/// provider acceptance is inferred from this source fixture.
#[tokio::test]
async fn canonical_cli_control_uses_authenticated_owner_and_same_dispatcher() {
use axum::{Json, Router, routing::get};
let harness = Harness::new("thread-control");
let workspace = harness._config_dir.path().to_path_buf();
let thread = serde_json::json!({"id":"canonical-transport","created_at":"2026-10-02T00:00:00Z",
"updated_at":"2026-10-02T00:00:00Z","model":"fixture-model","model_provider":"custom",
"model_provider_id":"fixture-owner","workspace":workspace,"archived":false});
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let endpoint = listener.local_addr().unwrap();
let app = Router::new()
.route(
"/v1/threads",
get(move || {
let thread = thread.clone();
async move { Json(serde_json::json!([thread])) }
}),
)
.route(
"/v1/threads/running",
get(|| async { Json(serde_json::json!([])) }),
);
let fake_http = tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let owner = codewhale_protocol::RuntimeOwnerReceipt {
version: 1,
data_dir: harness.root.join("runtime"),
@@ -863,14 +874,34 @@ async fn canonical_cli_control_uses_authenticated_owner_and_same_dispatcher() {
socket_path: harness.socket_path.clone(),
config_path: harness.options().config_path,
};
let daemon = codewhale_app_server::bind_runtime_owner(
let frontend = std::sync::Arc::new(ScopedControlFixture {
workers: 0,
config: owner.config_path.clone().unwrap(),
scopes: Default::default(),
owner: owner.clone(),
target: workspace.clone(),
records: vec![thread],
calls: Default::default(),
});
let (daemon, _) = codewhale_app_server::bind_runtime_frontends(
owner.config_path.clone(),
endpoint,
Some("private-fixture-control".into()),
owner.clone(),
codewhale_app_server::RuntimeOwnerRouting {
endpoint: "127.0.0.1:1".parse().unwrap(),
workspace: None,
workers: None,
mobile: false,
web: false,
acp: true,
acp_only: false,
},
Some(frontend),
None,
)
.await
.unwrap();
let daemon = daemon.expect("a private endpoint directory is available on this host");
let shutdown = daemon.shutdown_handle();
let socket = tokio::spawn(daemon.serve());
let result = codewhale_app_server::request_thread_control(
@@ -914,7 +945,6 @@ async fn canonical_cli_control_uses_authenticated_owner_and_same_dispatcher() {
.unwrap()
.unwrap()
.unwrap();
fake_http.abort();
}
/// This only validates the adapter's captured scope transport. Actual Runtime
@@ -923,8 +953,87 @@ struct ScopedControlFixture {
workers: usize,
config: PathBuf,
scopes: std::sync::Arc<tokio::sync::Mutex<Vec<codewhale_app_server::RuntimeFrontendScope>>>,
owner: codewhale_protocol::RuntimeOwnerReceipt,
target: PathBuf,
records: Vec<Value>,
calls: std::sync::Arc<tokio::sync::Mutex<Vec<(String, Value)>>>,
}
fn thread_record(id: &str, root: &Path) -> Value {
json!({"id":id,"created_at":"2026-10-02T00:00:00Z",
"updated_at":"2026-10-02T00:00:00Z","model":"fixture-model","model_provider":"custom",
"model_provider_id":"fixture-owner","workspace":root,"archived":false})
}
impl codewhale_app_server::RuntimeOwnerFrontend for ScopedControlFixture {
fn execute(
&self,
request: codewhale_app_server::RuntimeOwnerRequest,
) -> std::pin::Pin<
Box<dyn std::future::Future<Output = anyhow::Result<serde_json::Value>> + Send + '_>,
> {
use codewhale_app_server::RuntimeOwnerRequest as Request;
Box::pin(async move {
let owner = &self.owner;
let (name, body) = match &request {
Request::HistoryLookup(body) => ("lookup", serde_json::to_value(body)?),
Request::HistoryMutate(body) => ("mutate", serde_json::to_value(body)?),
Request::HistorySnapshot { thread_id } => ("history", json!(thread_id)),
Request::GetThread { thread_id } => ("thread", json!(thread_id)),
Request::ListThreads { .. } => ("list", Value::Null),
Request::RunningThreads => ("running", Value::Null),
_ => anyhow::bail!("this captured frontend admits no ordinary native service"),
};
self.calls.lock().await.push((name.into(), body.clone()));
Ok(match request {
Request::HistoryLookup(_) => json!({"state":"absent"}),
Request::HistoryMutate(_) => {
anyhow::ensure!(body["expected_data_dir"] == json!(owner.data_dir));
anyhow::ensure!(body["expected_execution_scope"] == owner.execution_scope);
anyhow::ensure!(body["workspace"] == json!(self.target));
anyhow::ensure!(
body["mutation"]["options"]["overrides"]
== json!({"model":"explicit-model", "model_provider":"fixture-owner", "approval_policy":"on-request", "sandbox":"workspace-write"})
);
let fork = body["mutation"]["action"] == "fork";
json!({"version":1,"data_dir":owner.data_dir,"execution_scope":owner.execution_scope,
"operation_key":body["operation_key"],"request_digest":"1".repeat(64),"history_digest":"2".repeat(64),
"runtime_thread_id":if fork {"canonical-scoped-fork"} else {"canonical-source"},
"session_id":if fork {"session-scoped-fork"} else {"session-source"}})
}
Request::HistorySnapshot { .. } => {
json!({"version":1,"data_dir":owner.data_dir,"execution_scope":owner.execution_scope,
"runtime_thread_id":"canonical-source","saved_session_id":"session-source",
"saved_document_digest":"a".repeat(64),"session_goal_digest":"74234e98afe7498fb5daf1f36ac2d78acc339464f950703b8c019892f982b90b",
"document_digest":"b".repeat(64),"session":{"metadata":{"id":"session-source"},"messages":[],"journal":{"entries":[]}}})
}
Request::RunningThreads => json!([]),
Request::ListThreads { .. } => json!(self.records),
Request::GetThread { thread_id }
if thread_id == "canonical-source" || thread_id == "canonical-scoped-fork" =>
{
thread_record(&thread_id, &self.target)
}
_ => anyhow::bail!("fixture route missing"),
})
})
}
fn events(
&self,
_thread_id: String,
_since_seq: u64,
) -> std::pin::Pin<
Box<
dyn std::future::Future<
Output = anyhow::Result<codewhale_app_server::RuntimeOwnerEventStream>,
> + Send
+ '_,
>,
> {
Box::pin(async {
anyhow::bail!("this captured frontend admits no ordinary native observation")
})
}
fn validate_selection<'a>(
&'a self,
selection: &'a codewhale_app_server::RuntimeOwnerFrontendSelection,
@@ -960,13 +1069,13 @@ impl codewhale_app_server::RuntimeOwnerFrontend for ScopedControlFixture {
Ok(())
})
}
fn serve(
&self,
fn serve<'a>(
&'a self,
selection: codewhale_app_server::RuntimeOwnerFrontendSelection,
compatibility: codewhale_app_server::AppState,
input: Box<dyn tokio::io::AsyncBufRead + Send + Unpin>,
output: Box<dyn tokio::io::AsyncWrite + Send + Unpin>,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = anyhow::Result<()>> + Send + '_>> {
output: Box<dyn tokio::io::AsyncWrite + Send + Unpin + 'a>,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = anyhow::Result<()>> + Send + 'a>> {
Box::pin(async move {
let codewhale_app_server::RuntimeOwnerFrontendSelection::Control(scope) = selection
else {
@@ -980,7 +1089,6 @@ impl codewhale_app_server::RuntimeOwnerFrontend for ScopedControlFixture {
#[tokio::test]
async fn canonical_cli_scoped_resume_fork_keep_exact_owner_workers_and_refuse_wrong_selection() {
use axum::{Json, Router, response::IntoResponse as _};
use std::sync::Arc;
let harness = Harness::new("scoped-control");
let workspace = harness._config_dir.path().to_path_buf();
@@ -1003,53 +1111,19 @@ async fn canonical_cli_scoped_resume_fork_keep_exact_owner_workers_and_refuse_wr
config_path: harness.options().config_path,
};
let calls = Arc::new(tokio::sync::Mutex::new(Vec::<(String, Value)>::new()));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let endpoint = listener.local_addr().unwrap();
let captured = owner.clone();
let http_calls = calls.clone();
let target = selected.clone();
let app = Router::new().fallback(move |request: axum::extract::Request| {
let owner = captured.clone(); let calls = http_calls.clone(); let target = target.clone();
async move {
let path = request.uri().path().to_string();
let bytes = axum::body::to_bytes(request.into_body(), 8 * 1024 * 1024).await.unwrap();
let body = if bytes.is_empty() { Value::Null } else { serde_json::from_slice(&bytes).unwrap() };
calls.lock().await.push((path.clone(), body.clone()));
let record = |id: &str, root: &Path| json!({"id":id,"created_at":"2026-10-02T00:00:00Z",
"updated_at":"2026-10-02T00:00:00Z","model":"fixture-model","model_provider":"custom",
"model_provider_id":"fixture-owner","workspace":root,"archived":false});
let value = if path.ends_with("/operations/lookup") { json!({"state":"absent"}) }
else if path.ends_with("/mutate") {
assert_eq!(body["expected_data_dir"], json!(owner.data_dir));
assert_eq!(body["expected_execution_scope"], owner.execution_scope);
assert_eq!(body["workspace"], json!(target));
assert_eq!(body["mutation"]["options"]["overrides"], json!({"model":"explicit-model", "model_provider":"fixture-owner", "approval_policy":"on-request", "sandbox":"workspace-write"}));
let fork = body["mutation"]["action"] == "fork";
json!({"version":1,"data_dir":owner.data_dir,"execution_scope":owner.execution_scope,
"operation_key":body["operation_key"],"request_digest":"1".repeat(64),"history_digest":"2".repeat(64),
"runtime_thread_id":if fork {"canonical-scoped-fork"} else {"canonical-source"},
"session_id":if fork {"session-scoped-fork"} else {"session-source"}})
} else if path.ends_with("/history") {
json!({"version":1,"data_dir":owner.data_dir,"execution_scope":owner.execution_scope,
"runtime_thread_id":"canonical-source","saved_session_id":"session-source",
"saved_document_digest":"a".repeat(64),"session_goal_digest":"74234e98afe7498fb5daf1f36ac2d78acc339464f950703b8c019892f982b90b",
"document_digest":"b".repeat(64),"session":{"metadata":{"id":"session-source"},"messages":[],"journal":{"entries":[]}}})
} else if path == "/v1/threads/running" { json!([]) }
else if path == "/v1/threads" { json!([record("canonical-source", &target), record("other-workspace", Path::new("/recorded-other-workspace"))]) }
else if path == "/v1/threads/canonical-scoped-fork" { record("canonical-scoped-fork", &target) }
else if path == "/v1/threads/canonical-source" { record("canonical-source", &target) }
else { return (axum::http::StatusCode::NOT_FOUND, Json(json!({"error":"fixture route missing"}))).into_response(); };
Json(value).into_response()
}
});
let fake_http = tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let endpoint: std::net::SocketAddr = "127.0.0.1:1".parse().unwrap();
let scopes = Arc::new(tokio::sync::Mutex::new(Vec::new()));
let frontend = Arc::new(ScopedControlFixture {
workers: 7,
config: owner.config_path.clone().unwrap(),
scopes: scopes.clone(),
owner: owner.clone(),
target: selected.clone(),
records: vec![
thread_record("canonical-source", &selected),
thread_record("other-workspace", Path::new("/recorded-other-workspace")),
],
calls: calls.clone(),
});
let (daemon, _) = codewhale_app_server::bind_runtime_frontends(
owner.config_path.clone(),
@@ -1144,7 +1218,7 @@ async fn canonical_cli_scoped_resume_fork_keep_exact_owner_workers_and_refuse_wr
assert_eq!(
calls.lock().await.len(),
before,
"selection refusal happens before HTTP effect dispatch"
"selection refusal happens before native effect dispatch"
);
shutdown.trigger();
tokio::time::timeout(Duration::from_secs(5), socket)
@@ -1152,5 +1226,4 @@ async fn canonical_cli_scoped_resume_fork_keep_exact_owner_workers_and_refuse_wr
.unwrap()
.unwrap()
.unwrap();
fake_http.abort();
}
+1
View File
@@ -41,6 +41,7 @@ codewhale-release = { path = "../release", version = "0.10.3" }
codewhale-secrets = { path = "../secrets", version = "0.10.3" }
codewhale-state = { path = "../state", version = "0.10.3" }
codewhale-telemetry = { path = "../telemetry", version = "0.10.3" }
codewhale-util = { path = "../util", version = "0.10.3" }
chrono.workspace = true
console = "0.16.3"
dirs.workspace = true
+74 -20
View File
@@ -405,7 +405,7 @@ Transports:
codewhale app-server --http Full HTTP/SSE runtime API (/v1/*) on 127.0.0.1:7878
codewhale app-server --mobile Runtime API + phone control page (127.0.0.1 only)
codewhale app-server --stdio Compatibility JSON-RPC controls and tool-free turns
codewhale app-server Compatibility HTTP routes on the canonical owner at 127.0.0.1:8787
codewhale app-server Initialized JSONL control over stdio on the canonical owner
`--http` and `--mobile` serve the same mature runtime API as `codewhale serve
--http`/`--mobile`, which remain as compatibility aliases. The runtime API token
@@ -457,7 +457,8 @@ The command prints the completion script to stdout; redirect it to a path your s
#[arg(value_enum)]
shell: Shell,
},
/// Print a usage rollup from the audit log and session store.
/// Print persisted usage, cache, tool and Fleet diagnostics.
#[command(visible_alias = "stats")]
Metrics(MetricsArgs),
/// Update this release binary from GitHub (package-managed installs get migration instructions).
#[command(
@@ -545,6 +546,7 @@ const ADVANCED_COMMANDS: &[&str] = &[
"scorecard",
"session-diagnostics",
"metrics",
"stats",
"providers",
"completion",
];
@@ -553,8 +555,9 @@ fn user_facing_command() -> clap::Command {
let mut cmd = Cli::command();
let mut listed = Vec::new();
for name in ADVANCED_COMMANDS {
if cmd.find_subcommand(name).is_some() {
cmd = cmd.mut_subcommand(name, |sub| sub.hide(true));
if let Some(subcommand) = cmd.find_subcommand(name) {
let canonical_name = subcommand.get_name().to_owned();
cmd = cmd.mut_subcommand(canonical_name, |sub| sub.hide(true));
listed.push(*name);
}
}
@@ -718,8 +721,39 @@ struct MetricsArgs {
#[arg(long)]
json: bool,
/// Restrict to events newer than this duration (e.g. 7d, 24h, 30m, now-2h).
#[arg(long, value_name = "DURATION")]
#[arg(long, value_name = "DURATION", conflicts_with = "days")]
since: Option<String>,
#[arg(
long,
value_name = "N",
conflicts_with = "since",
help = "Inspect the last N days (positive)"
)]
days: Option<u32>,
#[arg(
long,
value_name = "N",
help = "Show the top N identified model rows by known tokens"
)]
models: Option<usize>,
#[arg(
long,
value_name = "N",
help = "Show the top N tools by identified executions"
)]
tools: Option<usize>,
#[arg(
long,
value_name = "WORKSPACE",
help = "Filter current workspace views; original-source money attribution may be unavailable"
)]
project: Option<PathBuf>,
#[arg(
long,
value_name = "N",
help = "Show the top N recorded Fleet roles by task count"
)]
roles: Option<usize>,
}
#[derive(Debug, Args)]
@@ -2083,7 +2117,7 @@ struct AppServerArgs {
/// Equivalent to the legacy `codewhale serve --mobile`.
#[arg(long, conflicts_with = "stdio")]
mobile: bool,
/// Run the smaller compatibility JSON-RPC control transport over stdio.
/// Run initialized compatibility control over stdio (also the bare default).
/// Tool-free turns require allowed_tools: []; use --http for interactive work.
#[arg(long, default_value_t = false)]
stdio: bool,
@@ -2106,8 +2140,7 @@ struct AppServerArgs {
/// not widen the bind, and a non-loopback mobile bind is rejected.
#[arg(long)]
host: Option<String>,
/// Bind port. Defaults to 7878 for --http/--mobile (the runtime API) and
/// 8787 for the legacy in-process app-server HTTP transport.
/// Bind port for --http/--mobile. Defaults to 7878.
#[arg(long)]
port: Option<u16>,
/// Background task worker count (1-8). Only used with --http/--mobile.
@@ -2133,7 +2166,7 @@ pub fn run_cli() -> std::process::ExitCode {
install_rustls_crypto_provider();
let outcome = run();
codewhale_tui::discard_runtime_listener_handoff();
codewhale_app_server::runtime_api::listener::discard_runtime_listener_handoff();
// A config write may have moved legacy top-level `base_url` / `api_key`
// into their provider tables (#6394); say so once, off stdout.
for notice in codewhale_config::legacy_root::take_notices() {
@@ -2351,7 +2384,8 @@ fn argv_secret_warning(cli: &Cli, command: Option<&Commands>) -> Option<&'static
}
fn run() -> Result<()> {
let inherited_listener = codewhale_tui::prepare_runtime_listener_handoff()?;
let inherited_listener =
codewhale_app_server::runtime_api::listener::prepare_runtime_listener_handoff()?;
let argv: Vec<_> = std::env::args_os().collect();
let matches = user_facing_command().get_matches_from(&argv);
let project_bundle_scope = config_command_targets_project(&matches);
@@ -6521,21 +6555,16 @@ fn run_app_server_command(
path: args.socket_path.clone(),
})
} else if !args.http && !args.mobile {
eprintln!(
"note: bare app-server selects the compatibility API on port 8787 by default; use codewhale app-server --http for the full /v1/* API, approvals and SSE events"
);
Some(RuntimeControlFrontend::LegacyHttp)
Some(RuntimeControlFrontend::Stdio)
} else {
None
};
let mut launch = args;
if launch.port.is_none() {
launch.port = Some(if launch.stdio || launch.socket {
0
} else if launch.http || launch.mobile {
launch.port = Some(if launch.http || launch.mobile {
7878
} else {
8787
0
});
}
if !launch.http && !launch.mobile {
@@ -6821,11 +6850,25 @@ fn run_metrics_command(args: MetricsArgs) -> Result<()> {
Some(s) => {
Some(metrics::parse_since(s).with_context(|| format!("invalid --since value: {s:?}"))?)
}
None => None,
None => args
.days
.map(|days| {
if days == 0 {
bail!("--days must be positive");
}
chrono::Utc::now()
.checked_sub_signed(chrono::Duration::days(i64::from(days)))
.ok_or_else(|| anyhow!("--days reaches before the supported time range"))
})
.transpose()?,
};
metrics::run(metrics::MetricsArgs {
json: args.json,
since,
models: args.models,
tools: args.tools,
project: args.project,
roles: args.roles,
})
}
@@ -13667,7 +13710,18 @@ verbosity = "concise"
"codewhale completion elvish >> ~/.config/elvish/rc.elv",
],
),
("metrics", vec!["--json", "--since"]),
(
"metrics",
vec![
"--json",
"--since",
"--days",
"--models",
"--tools",
"--project",
"--roles",
],
),
];
for (subcommand, expected_tokens) in cases {
+242 -17
View File
@@ -1,7 +1,7 @@
//! `codewhale metrics` — reads the audit log and session/task stores and prints
//! `codewhale metrics` / `stats` — reads native and legacy stores and prints
//! a human-readable usage rollup.
//!
//! Data sources, all resolved through the shared Codewhale state resolvers so
//! Legacy receipt sources, resolved through the shared Codewhale state resolvers so
//! the reader lands on the same files the writers use:
//! - `~/.codewhale/audit.log` — one JSON line per event (approvals, credentials)
//! - `~/.codewhale/sessions/` — saved session JSON files (tool call history)
@@ -17,20 +17,23 @@
//! excluding records copied across roots. An explicit `CODEWHALE_HOME` never
//! reads outside that root.
//!
//! The three sources overlap and are deliberately not de-duplicated against
//! The three legacy views overlap and are deliberately not de-duplicated against
//! each other: an approval receipt, a saved-session transcript entry, and a
//! runtime `item.*` receipt each describe one tool call from a different
//! vantage point, and collapsing them would assert an identity the data does
//! not carry. Cross-reference with `--json` when an exact count matters.
//!
//! There is no fourth source. The opt-in tool audit file behind
//! `CODEWHALE_TOOL_AUDIT_LOG` / `DEEPSEEK_TOOL_AUDIT_LOG` (`emit_tool_audit`)
//! is a *different* file from `~/.codewhale/audit.log` and is not discovered
//! here. Because that variable can be pointed at the audit log itself, and
//! The opt-in tool audit file behind `CODEWHALE_TOOL_AUDIT_LOG` /
//! `DEEPSEEK_TOOL_AUDIT_LOG` (`emit_tool_audit`) is separate from
//! `~/.codewhale/audit.log`; the legacy receipt projection does not discover
//! it. Because that variable can be pointed at the audit log itself, and
//! because `emit_tool_audit` puts `tool_name` and `success` at the JSON top
//! level rather than under `details`, the audit reader accepts both shapes.
mod stats;
use std::collections::{HashMap, HashSet};
use std::io::{self, BufRead, Read};
use std::path::{Path, PathBuf};
use anyhow::Result;
@@ -49,6 +52,10 @@ pub struct MetricsArgs {
pub json: bool,
/// Restrict to events newer than this cutoff (inclusive).
pub since: Option<DateTime<Utc>>,
pub models: Option<usize>,
pub tools: Option<usize>,
pub project: Option<PathBuf>,
pub roles: Option<usize>,
}
pub fn run(args: MetricsArgs) -> Result<()> {
@@ -61,9 +68,35 @@ pub fn run(args: MetricsArgs) -> Result<()> {
let sessions = codewhale_config::resolve_state_dir("sessions")?;
let tasks = codewhale_config::resolve_state_dir("tasks")?;
let runtime_events = runtime_event_dirs(&tasks, &sessions);
anyhow::ensure!(
[args.models, args.tools, args.roles]
.into_iter()
.flatten()
.all(|limit| limit > 0),
"--models, --tools and --roles must be positive"
);
let report = stats::collect(&args, &sessions, &runtime_events)?;
if args.project.is_some() {
if args.json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"stats": report,
"legacy_receipt_projections": null,
"legacy_receipt_projection_scope": "unavailable for a workspace filter; old receipts do not all carry that scope",
}))?
);
} else {
stats::print_human(&report);
println!("Legacy receipt projections: unavailable for a workspace filter.");
}
return Ok(());
}
// Collect data from every source; treat missing files as empty.
let mut rollup = Rollup::default();
let mut rollup = Rollup {
stats: Some(report),
..Default::default()
};
read_audit_history(&audit_roots, args.since, &mut rollup);
read_session_files(&sessions, args.since, &mut rollup);
read_runtime_events(&runtime_events, args.since, &mut rollup);
@@ -71,6 +104,12 @@ pub fn run(args: MetricsArgs) -> Result<()> {
if args.json {
print_json(&rollup)?;
} else {
if let Some(report) = &rollup.stats {
stats::print_human(report);
}
println!(
"\nLegacy receipt projections below overlap and are not added to native-source money or unique execution counts."
);
print_human(&rollup, args.since);
}
@@ -389,6 +428,12 @@ pub struct RuntimeRequestStats {
/// Top-level rollup.
#[derive(Debug, Default, serde::Serialize)]
pub struct Rollup {
stats: Option<stats::StatsReport>,
pub receipt_read_gaps: u64,
#[serde(skip)]
receipt_inspection_bytes: usize,
#[serde(skip)]
receipt_inspection_exhausted: bool,
/// UTC timestamp of the earliest event we've seen.
pub earliest_ts: Option<DateTime<Utc>>,
/// UTC timestamp of the latest event we've seen.
@@ -413,6 +458,27 @@ struct RuntimeEventDedup {
}
impl Rollup {
fn admit_receipt_inspection(&mut self, value: &Value) -> bool {
if self.receipt_inspection_exhausted {
return false;
}
let bytes = value.to_string().len();
let next = self.receipt_inspection_bytes.checked_add(bytes);
match next {
Some(next)
if next <= codewhale_engine::session_manager::SESSION_METADATA_READ_LIMIT =>
{
self.receipt_inspection_bytes = next;
true
}
_ => {
self.receipt_read_gaps = self.receipt_read_gaps.saturating_add(1);
self.receipt_inspection_exhausted = true;
false
}
}
}
fn touch_ts(&mut self, ts: &DateTime<Utc>) {
match self.earliest_ts {
None => self.earliest_ts = Some(*ts),
@@ -439,10 +505,99 @@ impl Rollup {
// Source readers
// ──────────────────────────────────────────────────────────────────────────────
fn open_diagnostic_file(path: &Path) -> io::Result<std::fs::File> {
let parent = path.parent().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
"diagnostic record has no parent",
)
})?;
let name = path.file_name().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
"diagnostic record has no filename",
)
})?;
codewhale_util::files::WorkspaceFile::open_resolved_root(parent, Path::new(name), false)?
.open_file()
}
fn read_diagnostic_text(path: &Path) -> io::Result<String> {
let mut raw = Vec::new();
let limit = codewhale_engine::session_manager::SESSION_METADATA_READ_LIMIT;
open_diagnostic_file(path)?
.take(limit as u64 + 1)
.read_to_end(&mut raw)?;
if raw.len() > limit {
return Err(io::Error::new(
io::ErrorKind::FileTooLarge,
"diagnostic record exceeds the session inspection limit",
));
}
String::from_utf8(raw)
.map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "diagnostic record is not UTF-8"))
}
fn read_diagnostic_json(path: &Path) -> Result<Value> {
Ok(serde_json::from_str(&read_diagnostic_text(path)?)?)
}
struct DiagnosticLines {
reader: std::io::BufReader<std::fs::File>,
finished: bool,
}
impl Iterator for DiagnosticLines {
type Item = io::Result<String>;
fn next(&mut self) -> Option<Self::Item> {
if self.finished {
return None;
}
let mut raw = Vec::new();
let limit = codewhale_engine::session_manager::SESSION_METADATA_READ_LIMIT;
let result = self
.reader
.by_ref()
.take(limit as u64 + 1)
.read_until(b'\n', &mut raw);
match result {
Ok(0) => {
self.finished = true;
None
}
Ok(_) if raw.len() > limit => {
self.finished = true;
Some(Err(io::Error::new(
io::ErrorKind::FileTooLarge,
"diagnostic frame exceeds the session inspection limit",
)))
}
Ok(_) => Some(String::from_utf8(raw).map_err(|_| {
io::Error::new(io::ErrorKind::InvalidData, "diagnostic frame is not UTF-8")
})),
Err(error) => {
self.finished = true;
Some(Err(error))
}
}
}
}
fn diagnostic_lines(file: std::fs::File) -> DiagnosticLines {
DiagnosticLines {
reader: std::io::BufReader::new(file),
finished: false,
}
}
/// Read both retained generations from each root. A copied legacy record is
/// counted once across roots, while repeated records within one root retain
/// their multiplicity. No source log is rewritten or removed.
fn read_audit_history(roots: &[PathBuf], since: Option<DateTime<Utc>>, rollup: &mut Rollup) {
if rollup.receipt_inspection_exhausted {
return;
}
let mut earlier_roots = HashMap::new();
for root in roots {
let mut root_counts = HashMap::new();
@@ -470,10 +625,14 @@ fn read_audit_log(
earlier_roots: &HashMap<[u8; 32], u64>,
root_counts: &mut HashMap<[u8; 32], u64>,
) {
let file = match std::fs::File::open(path) {
if rollup.receipt_inspection_exhausted {
return;
}
let file = match open_diagnostic_file(path) {
Ok(file) => file,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return,
Err(e) => {
rollup.receipt_read_gaps = rollup.receipt_read_gaps.saturating_add(1);
tracing::trace!(
"metrics: could not read audit log {}: {}",
path.display(),
@@ -485,8 +644,9 @@ fn read_audit_log(
// Streamed line by line: an append-only log is unbounded, and holding it
// whole only to aggregate one line at a time made allocation scale with it.
for raw_line in std::io::BufRead::lines(std::io::BufReader::new(file)) {
for raw_line in diagnostic_lines(file) {
let Ok(raw_line) = raw_line.inspect_err(|e| {
rollup.receipt_read_gaps = rollup.receipt_read_gaps.saturating_add(1);
tracing::trace!("metrics: stopped reading audit log {}: {e}", path.display());
}) else {
break;
@@ -500,11 +660,21 @@ fn read_audit_log(
let v: Value = match serde_json::from_str(line) {
Ok(v) => v,
Err(e) => {
rollup.receipt_read_gaps = rollup.receipt_read_gaps.saturating_add(1);
tracing::trace!("metrics: skipping malformed audit line: {e}");
continue;
}
};
if let Some(cutoff) = since
&& parse_ts_field(&v, "ts").is_some_and(|timestamp| timestamp < cutoff)
{
continue;
}
if !rollup.admit_receipt_inspection(&v) {
break;
}
// Copy migration preserves the complete event, including its timestamp.
// Count occurrences so two identical legitimate records in one source
// are not collapsed into one merely because another root also exists.
@@ -673,10 +843,14 @@ fn read_audit_log(
/// Read session JSON files under `sessions/` (one per session).
/// These carry tool call history with optional elapsed_ms and result data.
fn read_session_files(sessions_dir: &Path, since: Option<DateTime<Utc>>, rollup: &mut Rollup) {
if rollup.receipt_inspection_exhausted {
return;
}
let rd = match std::fs::read_dir(sessions_dir) {
Ok(rd) => rd,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return,
Err(e) => {
rollup.receipt_read_gaps = rollup.receipt_read_gaps.saturating_add(1);
tracing::trace!(
"metrics: could not list sessions dir {}: {}",
sessions_dir.display(),
@@ -686,7 +860,17 @@ fn read_session_files(sessions_dir: &Path, since: Option<DateTime<Utc>>, rollup:
}
};
for entry in rd.flatten() {
for entry in rd {
let entry = match entry {
Ok(entry) => entry,
Err(_) => {
rollup.receipt_read_gaps = rollup.receipt_read_gaps.saturating_add(1);
continue;
}
};
if rollup.receipt_inspection_exhausted {
break;
}
let path = entry.path();
// Only look at .json files directly in sessions/; skip sub-dirs.
if path.is_dir() || path.extension().map(|e| e != "json").unwrap_or(true) {
@@ -697,9 +881,13 @@ fn read_session_files(sessions_dir: &Path, since: Option<DateTime<Utc>>, rollup:
}
fn read_session_file(path: &Path, since: Option<DateTime<Utc>>, rollup: &mut Rollup) {
let content = match std::fs::read_to_string(path) {
if rollup.receipt_inspection_exhausted {
return;
}
let content = match read_diagnostic_text(path) {
Ok(c) => c,
Err(e) => {
rollup.receipt_read_gaps = rollup.receipt_read_gaps.saturating_add(1);
tracing::trace!(
"metrics: could not read session file {}: {}",
path.display(),
@@ -714,6 +902,7 @@ fn read_session_file(path: &Path, since: Option<DateTime<Utc>>, rollup: &mut Rol
let v: Value = match serde_json::from_str(&content) {
Ok(v) => v,
Err(e) => {
rollup.receipt_read_gaps = rollup.receipt_read_gaps.saturating_add(1);
tracing::trace!(
"metrics: skipping malformed session file {}: {}",
path.display(),
@@ -723,6 +912,10 @@ fn read_session_file(path: &Path, since: Option<DateTime<Utc>>, rollup: &mut Rol
}
};
if !rollup.admit_receipt_inspection(&v) {
return;
}
rollup.parsed_lines += 1;
// Session-level timestamp filter (check metadata.created_at or updated_at).
@@ -859,6 +1052,9 @@ fn runtime_dir_override() -> Option<PathBuf> {
/// Read every runtime event root under one de-duplication scope, so the same
/// `(thread_id, seq)` receipt is counted once however many roots list it.
fn read_runtime_events(events_dirs: &[PathBuf], since: Option<DateTime<Utc>>, rollup: &mut Rollup) {
if rollup.receipt_inspection_exhausted {
return;
}
let mut dedup = RuntimeEventDedup::default();
for dir in events_dirs {
read_runtime_events_dir(dir, since, rollup, &mut dedup);
@@ -872,10 +1068,22 @@ fn read_runtime_events_dir(
rollup: &mut Rollup,
dedup: &mut RuntimeEventDedup,
) {
if rollup.receipt_inspection_exhausted {
return;
}
match std::fs::symlink_metadata(events_dir) {
Ok(metadata) if metadata.is_dir() => {}
Err(error) if error.kind() == io::ErrorKind::NotFound => return,
_ => {
rollup.receipt_read_gaps = rollup.receipt_read_gaps.saturating_add(1);
return;
}
}
let rd = match std::fs::read_dir(events_dir) {
Ok(rd) => rd,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return,
Err(e) => {
rollup.receipt_read_gaps = rollup.receipt_read_gaps.saturating_add(1);
tracing::trace!(
"metrics: could not list events dir {}: {}",
events_dir.display(),
@@ -885,7 +1093,14 @@ fn read_runtime_events_dir(
}
};
for entry in rd.flatten() {
for entry in rd {
let entry = match entry {
Ok(entry) => entry,
Err(_) => {
rollup.receipt_read_gaps = rollup.receipt_read_gaps.saturating_add(1);
continue;
}
};
let path = entry.path();
if path.extension().map(|e| e != "jsonl").unwrap_or(true) {
continue;
@@ -900,9 +1115,13 @@ fn read_events_jsonl(
rollup: &mut Rollup,
dedup: &mut RuntimeEventDedup,
) {
let file = match std::fs::File::open(path) {
if rollup.receipt_inspection_exhausted {
return;
}
let file = match open_diagnostic_file(path) {
Ok(file) => file,
Err(e) => {
rollup.receipt_read_gaps = rollup.receipt_read_gaps.saturating_add(1);
tracing::trace!(
"metrics: could not read events file {}: {}",
path.display(),
@@ -913,8 +1132,9 @@ fn read_events_jsonl(
};
// Streamed like the audit log: allocation follows one line, not the file.
for raw_line in std::io::BufRead::lines(std::io::BufReader::new(file)) {
for raw_line in diagnostic_lines(file) {
let Ok(raw_line) = raw_line.inspect_err(|e| {
rollup.receipt_read_gaps = rollup.receipt_read_gaps.saturating_add(1);
tracing::trace!(
"metrics: stopped reading events file {}: {e}",
path.display()
@@ -931,6 +1151,7 @@ fn read_events_jsonl(
let v: Value = match serde_json::from_str(line) {
Ok(v) => v,
Err(e) => {
rollup.receipt_read_gaps = rollup.receipt_read_gaps.saturating_add(1);
tracing::trace!("metrics: skipping malformed event line: {e}");
continue;
}
@@ -945,6 +1166,10 @@ fn read_events_jsonl(
}
}
if !rollup.admit_receipt_inspection(&v) {
break;
}
rollup.parsed_lines += 1;
if let Some(t) = &ts {
rollup.touch_ts(t);
@@ -958,7 +1183,7 @@ fn read_events_jsonl(
// Tool and compaction receipts are durable *item* records. The
// `tool.started` / `tool.completed` names this reader used to
// match are synthesized for HTTP clients by
// `map_compat_stream_event` (`crates/tui/src/runtime_api.rs`) and
// `map_compat_stream_event` (`crates/app-server/src/runtime_api.rs`) and
// are never persisted, so those arms counted nothing.
"item.started" | "item.completed" | "item.failed" => {
record_runtime_item_receipt(event, &v, rollup, dedup);
File diff suppressed because it is too large. Load diff
+31 -2
View File
@@ -353,6 +353,31 @@ pub struct DebugCacheTurn {
pub age_seconds: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DebugCacheCallOwner {
Primary,
Auxiliary,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DebugCacheCallCoverage {
Reported,
Incomplete,
Missing,
NotDispatched,
Rejected,
Unknown,
}
#[derive(Debug, Clone, PartialEq)]
pub struct DebugCacheCall {
pub source_fingerprint: String,
pub owner: Option<DebugCacheCallOwner>,
pub coverage: DebugCacheCallCoverage,
pub pricing_complete: bool,
pub row: DebugCacheTurn,
}
/// Prompt-cache hit rates, each labelled by whose requests it covers (#6565).
///
/// `parent` is this conversation's own requests: the footer `cache N%` and it
@@ -389,14 +414,18 @@ impl DebugCacheRates {
}
}
/// Shared source for `/cache [count|stats|zones]` branches. One host read
/// preserves ring order, optional telemetry and prefix stability evidence.
/// Shared source for `/cache [count|calls|stats|zones]` branches. One host read
/// preserves turn order, bounded call projection, optional telemetry and prefix evidence.
#[derive(Debug, Clone, PartialEq)]
pub struct DebugCacheTelemetry {
pub model: String,
/// Parent/agent/combined percentages computed once by the host.
pub session_cache_rates: DebugCacheRates,
pub history: Vec<DebugCacheTurn>,
pub calls: Vec<DebugCacheCall>,
pub calls_total: usize,
pub calls_currency: CommandCurrency,
pub calls_legacy_coverage_unknown: bool,
pub history_capacity: usize,
pub prefix_stability_pct: Option<u32>,
pub prefix_checks_total: u64,
+7 -2
View File
@@ -93,7 +93,9 @@ pub type StructcopyCommandResult = CommandResult<std::convert::Infallible>;
/// Complete session-group vocabulary. Hosts execute these requests after dispatch.
#[derive(Debug, Clone, PartialEq)]
pub enum SessionAction {
CompactContext { focus: Option<String> },
CompactContext {
focus: Option<String>,
},
PurgeContext,
LoadSession(std::path::PathBuf),
SyncSession(SessionSyncPayload),
@@ -103,7 +105,10 @@ pub enum SessionAction {
},
SendMessage(String),
RemoteControl(SessionRemoteControlAction),
OpenExternalUrl { url: String, label: String },
OpenExternalUrl {
url: String,
label: String,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+4
View File
@@ -239,6 +239,10 @@ impl CommandDebugDiagnosticsContext for DebugDiagnostics {
model: "example".into(),
session_cache_rates: DebugCacheRates::default(),
history: vec![],
calls: vec![],
calls_total: 0,
calls_currency: CommandCurrency::Usd,
calls_legacy_coverage_unknown: false,
history_capacity: 50,
prefix_stability_pct: None,
prefix_checks_total: 0,
+22 -7
View File
@@ -1,4 +1,4 @@
//! `/cache` command — portable per-turn cache telemetry and inspection.
//! `/cache` command — portable turn/call cache telemetry and inspection.
//!
//! The host resolves the request route and pricing partitions once. This
//! handler checks flags, observes, formats and commits that same inspection;
@@ -7,9 +7,9 @@
use super::CommandResult;
use super::DebugAction as AppAction;
use super::cache_format::{
format_cache_history, format_cache_stats, format_cache_zones, format_first_divergence,
format_static_prefix_status, format_verbose_diff, format_warmup_status,
session_cache_rates_line,
format_cache_calls, format_cache_history, format_cache_stats, format_cache_zones,
format_first_divergence, format_static_prefix_status, format_verbose_diff,
format_warmup_status, session_cache_rates_line,
};
use codewhale_command_contract::facets::{
CommandDebugDiagnosticsContext, CommandPresentationContext, DebugCacheInspectionObservation,
@@ -25,7 +25,7 @@ pub struct CacheCmd;
const CONTRACT_INFO: ContractInfo = ContractInfo {
name: "cache",
aliases: &[],
usage: "/cache [count|inspect|stats|zones|warmup]",
usage: "/cache [count|calls [count]|inspect|stats|zones|warmup]",
description_key: "cmd_cache_description",
};
@@ -43,7 +43,7 @@ impl ContractRegisterCommand<CommandResult> for CacheCmd {
}
}
/// Show per-turn prefix-cache telemetry, a static status or an inspection.
/// Show turn/call prefix-cache telemetry, a static status or an inspection.
pub fn cache(contexts: CommandContexts<'_>, arg: Option<&str>) -> CommandResult {
let mut parts = contexts.into_parts();
let Some(diagnostics) = parts.debug_diagnostics.as_deref_mut() else {
@@ -61,6 +61,21 @@ fn cache_portable(
arg: Option<&str>,
) -> CommandResult {
let arg = arg.map(str::trim).filter(|s| !s.is_empty());
if let Some(count) = arg.and_then(|value| {
value
.strip_prefix("calls")
.filter(|rest| rest.is_empty() || rest.starts_with(char::is_whitespace))
}) {
let want = if count.trim().is_empty() {
10
} else {
match count.trim().parse::<usize>() {
Ok(count) => count,
Err(_) => return CommandResult::error("Usage: /cache calls [count]"),
}
};
return CommandResult::message(format_cache_calls(&diagnostics.cache_telemetry(), want));
}
let inspect_flags = arg.and_then(|a| {
if a == "inspect" {
Some("")
@@ -91,7 +106,7 @@ fn cache_portable(
Ok(n) => n,
Err(_) => {
return CommandResult::error(format!(
"Unknown /cache argument `{raw}`. Usage: /cache [count|inspect [--verbose|--json]|stats|zones|warmup]"
"Unknown /cache argument `{raw}`. Usage: /cache [count|calls [count]|inspect [--verbose|--json]|stats|zones|warmup]"
));
}
},
+107 -2
View File
@@ -3,8 +3,8 @@
use crate::diagnostics_reports::format_cost_amount_precise;
use codewhale_command_contract::facets::{
CommandPresentationContext, DebugCacheTelemetry, DebugCacheTurn, DebugCostProjection,
DebugPromptInspection, DebugWarmupKey,
CommandPresentationContext, DebugCacheCallCoverage, DebugCacheCallOwner, DebugCacheTelemetry,
DebugCacheTurn, DebugCostProjection, DebugPromptInspection, DebugWarmupKey,
};
use std::collections::{BTreeMap, BTreeSet};
@@ -467,6 +467,111 @@ fn turn_cost_cell(
"—".to_string()
}
pub(crate) fn format_cache_calls(telemetry: &DebugCacheTelemetry, count: usize) -> String {
let count = count
.min(telemetry.calls.len())
.min(telemetry.history_capacity);
let rows = telemetry
.calls
.iter()
.skip(telemetry.calls.len().saturating_sub(count));
let currency = match telemetry.calls_currency {
codewhale_command_contract::types::CommandCurrency::Usd => "USD",
codewhale_command_contract::types::CommandCurrency::Cny => "CNY",
};
let mut out = format!(
"Cache model-call receipts: {count} of {} retained sources ({currency})\n",
telemetry.calls_total
);
let mut spread: Option<(f64, f64, usize)> = None;
for call in rows {
let row = &call.row;
let owner = match call.owner {
Some(DebugCacheCallOwner::Primary) => "parent",
Some(DebugCacheCallOwner::Auxiliary) => "auxiliary",
None => "unknown owner",
};
let (coverage, usage_known) = match call.coverage {
DebugCacheCallCoverage::Reported => ("reported", true),
DebugCacheCallCoverage::Incomplete => ("incomplete", true),
DebugCacheCallCoverage::Missing => ("missing", false),
DebugCacheCallCoverage::NotDispatched => ("not dispatched", true),
DebugCacheCallCoverage::Rejected => ("rejected", true),
DebugCacheCallCoverage::Unknown => ("unknown", false),
};
let accounted = row
.priced_cache_read
.saturating_add(row.priced_cache_miss)
.saturating_add(row.priced_cache_write);
let ratio = if usage_known && row.cache_hit_tokens.is_some() && accounted > 0 {
let ratio = 100.0 * row.priced_cache_read as f64 / accounted as f64;
spread = Some(spread.map_or((ratio, ratio, 1), |(min, max, samples)| {
(min.min(ratio), max.max(ratio), samples + 1)
}));
format!("{ratio:.1}%")
} else {
"—".into()
};
let amount = row.priced_amount.map_or_else(
|| "—".into(),
|amount| {
let value = format_cost_amount_precise(amount, telemetry.calls_currency);
if call.pricing_complete {
value
} else {
format!("{value}*")
}
},
);
let input = if usage_known {
row.input_tokens.to_string()
} else {
"—".into()
};
let output = if usage_known {
row.output_tokens.to_string()
} else {
"—".into()
};
let hit = if usage_known && row.cache_hit_tokens.is_some() {
row.priced_cache_read.to_string()
} else {
"—".into()
};
let miss = if usage_known && row.cache_miss_tokens.is_some() {
row.priced_cache_miss.to_string()
} else if usage_known && row.cache_hit_tokens.is_some() {
format!("{}*", row.priced_cache_miss)
} else {
"—".into()
};
let write = if usage_known && row.cache_write_tokens.is_some() {
row.priced_cache_write.to_string()
} else {
"—".into()
};
out.push_str(&format!(
"\n{} {owner} {} {coverage}\n input {input} / output {output} / cache read {hit} / miss {miss} / write {write} / hit {ratio} / cost {amount} / quote age {}\n",
call.source_fingerprint,
format_turn_cache_route(row),
codewhale_command_contract::elapsed::format_elapsed_secs(row.age_seconds),
));
}
if telemetry.calls_total == 0 {
out.push_str("No retained native call receipts; /cache still shows legacy turn rows.\n");
}
if let Some((min, max, samples)) = spread {
out.push_str(&format!(
"\nReported cache-hit spread in shown rows: {min:.1}%–{max:.1}% ({samples} sources).\n"
));
}
out.push_str("\n— means not reported or not priced in the selected currency. Cost * is a known subtotal with incomplete coverage; miss * is inferred from the reported input partition. Known prices use saved frozen quotes and are estimates, not invoice charges. Quote age does not prove physical dispatch.\n");
if telemetry.calls_legacy_coverage_unknown {
out.push_str("Historical source coverage is unknown; legacy totals remain in /cost.\n");
}
out
}
pub(crate) fn format_cache_history(
telemetry: &DebugCacheTelemetry,
count: usize,
+2 -1
View File
@@ -252,7 +252,8 @@ impl McpServerConfig {
}
pub fn is_tool_always_allowed(&self, tool_name: &str) -> bool {
self.is_tool_enabled(tool_name) && self.always_allow_tools.iter().any(|tool| tool == tool_name)
self.is_tool_enabled(tool_name)
&& self.always_allow_tools.iter().any(|tool| tool == tool_name)
}
pub fn is_tool_enabled(&self, tool_name: &str) -> bool {
+1 -1
View File
@@ -3,7 +3,7 @@
pub enum RuntimeControlFrontend {
Stdio,
Socket { path: Option<std::path::PathBuf> },
LegacyHttp,
Http,
Acp { model: String },
}
+1 -2
View File
@@ -36,8 +36,6 @@ exclude = [
"src/commands/fixtures/*_baseline.md",
"src/commands/fixtures/*_baseline.json",
"src/pricing/default_coverage_unpriced.json",
"src/runtime_web/app.test.mjs",
"src/runtime_web/orca-evidence.html",
"src/tui/golden_harness.rs",
"src/tui/goldens/**",
"src/tui/pet_watch/worker.rs",
@@ -240,6 +238,7 @@ rio-vt = "0.5.1"
rusqlite.workspace = true
schemars = { version = "1.2.1", features = ["derive", "preserve_order"] }
tokio-tungstenite = "0.30"
codewhale-app-server = { path = "../app-server", version = "0.10.3", features = ["test-support"] }
[target.'cfg(unix)'.dependencies]
libc = "0.2"
+10 -1
View File
@@ -7275,7 +7275,9 @@ mod tests {
let mut bundle = app.command_contexts();
let mut parts = bundle.parts();
let facet = parts.lifecycle.as_deref_mut().expect("lifecycle slot");
let ok = facet.fresh_session(true).expect("force prepares a new session");
let ok = facet
.fresh_session(true)
.expect("force prepares a new session");
assert_eq!(app.current_session_id.as_deref(), Some("old-session"));
assert_eq!(app.input, "draft text");
assert_ne!(ok.sync.session_id.as_deref(), Some("old-session"));
@@ -7987,6 +7989,13 @@ mod tests {
let tmpdir = TempDir::new().unwrap();
let _lock = crate::test_support::lock_test_env();
let mut app = control_test_app(&tmpdir);
app.remote_control
.prepare_remote_control_session_journal(
&tmpdir.path().join("remote-control-journal"),
"control-test-target",
"control-test-session",
)
.expect("durable remote-control journal");
app.remote_control
.activate_prompt("run-1", "turn-1")
.unwrap();
@@ -149,10 +149,132 @@ impl CommandDebugDiagnosticsContext for DebugDiagnosticsAdapter<'_> {
}
})
.collect();
let mut latest_calls = BTreeMap::new();
for (source, contribution) in &app.session.accounted_usage_sources {
latest_calls.insert(
(contribution.record.usage.route.dispatched_at, source),
contribution,
);
if latest_calls.len() > App::TURN_CACHE_HISTORY_CAP {
latest_calls.pop_first();
}
}
let wall_now = chrono::Utc::now();
let calls = latest_calls
.into_iter()
.map(|((quoted_at, source), contribution)| {
let record = &contribution.record;
let route = &record.usage.route;
let display_route = route.sanitized_for_persistence();
let valid = contribution.validate().is_ok()
&& source.as_str()
== codewhale_engine::cost_status::usage_source_fingerprint(
&record.source_id,
);
let revision = record.settlement.as_ref().filter(|_| valid);
let coverage = revision.map_or(DebugCacheCallCoverage::Unknown, |revision| {
use codewhale_engine::llm_client::ProviderUsageCoverage;
match revision.coverage {
ProviderUsageCoverage::Reported => DebugCacheCallCoverage::Reported,
ProviderUsageCoverage::Incomplete => DebugCacheCallCoverage::Incomplete,
ProviderUsageCoverage::Missing => DebugCacheCallCoverage::Missing,
ProviderUsageCoverage::NotDispatched => {
DebugCacheCallCoverage::NotDispatched
}
ProviderUsageCoverage::Rejected => DebugCacheCallCoverage::Rejected,
}
});
let reported = matches!(
coverage,
DebugCacheCallCoverage::Reported | DebugCacheCallCoverage::Incomplete
);
let refused = matches!(
coverage,
DebugCacheCallCoverage::NotDispatched | DebugCacheCallCoverage::Rejected
);
let (amount, priced, unpriced) = match currency {
CostCurrency::Usd => {
(contribution.usd, contribution.priced, contribution.unpriced)
}
CostCurrency::Cny => (
contribution.cny,
contribution.cny_priced,
contribution.cny_unpriced,
),
};
let known_amount = (revision.is_some()
&& (refused || reported && (priced > 0 || amount > 0.0)))
.then_some(amount);
let usage = &record.usage.usage;
let cache_consistent =
codewhale_engine::pricing::usage_cache_partition_is_consistent(usage);
let classes = token_usage_for_pricing(usage);
let audit = route.audit_frozen(usage);
DebugCacheCall {
source_fingerprint: codewhale_engine::cost_status::usage_source_fingerprint(
&record.source_id,
),
owner: revision.map(|revision| match revision.owner {
codewhale_engine::cost_status::UsageSpendOwner::Primary => {
DebugCacheCallOwner::Primary
}
codewhale_engine::cost_status::UsageSpendOwner::Auxiliary => {
DebugCacheCallOwner::Auxiliary
}
}),
coverage,
pricing_complete: revision.is_some()
&& unpriced == 0
&& (priced > 0 || refused),
row: DebugCacheTurn {
provider: Some(display_route.provider.as_str().to_string()),
provider_identity: Some(display_route.provider_identity.clone()),
model: Some(display_route.model.clone()),
auto_model: false,
input_tokens: if reported { usage.input_tokens } else { 0 },
output_tokens: if reported { usage.output_tokens } else { 0 },
cache_hit_tokens: (reported && cache_consistent)
.then_some(usage.prompt_cache_hit_tokens)
.flatten(),
cache_miss_tokens: (reported && cache_consistent)
.then_some(usage.prompt_cache_miss_tokens)
.flatten(),
cache_write_tokens: (reported && cache_consistent)
.then_some(usage.prompt_cache_write_tokens)
.flatten(),
reasoning_tokens: reported.then_some(usage.reasoning_tokens).flatten(),
reasoning_replay_tokens: reported
.then_some(usage.reasoning_replay_tokens)
.flatten(),
priced_amount: known_amount,
unpriced_reason_key: audit
.unpriced_reason
.map(|reason| reason.label().to_string()),
unpriced_reason_sort_rank: audit.unpriced_reason.map(|reason| reason as u8),
unpriced_classes: audit
.unpriced_classes
.iter()
.map(|class| class.label().to_string())
.collect(),
priced_cache_read: classes.cache_read,
priced_cache_miss: classes.input,
priced_cache_write: classes.cache_write,
age_seconds: u64::try_from(
wall_now.signed_duration_since(quoted_at).num_seconds(),
)
.unwrap_or(0),
},
}
})
.collect();
DebugCacheTelemetry {
model: app.model.clone(),
session_cache_rates: crate::tui::session_metrics::cache_rates(&app),
history,
calls,
calls_total: app.session.accounted_usage_sources.len(),
calls_currency: super::to_command_currency(currency),
calls_legacy_coverage_unknown: app.session.cost_coverage_unknown_legacy,
history_capacity: App::TURN_CACHE_HISTORY_CAP,
prefix_stability_pct: app.prefix_stability_pct,
prefix_checks_total: app.prefix_checks_total,
@@ -615,8 +615,12 @@ pub(crate) fn restore_undo_range(plan: UndoRangePlan) -> anyhow::Result<String>
if plan.turns.iter().all(|turn| !turn.may_change_files) {
return Ok("Workspace files were left unchanged.".to_string());
}
let result = crate::runtime_api::patch_undo_workspace_files(&plan.workspace, &plan.turns, true)
.map_err(|error| anyhow::anyhow!(error.message))?;
let result = codewhale_app_server::runtime_api::patch_undo_workspace_files(
&plan.workspace,
&plan.turns,
true,
)
.map_err(|error| anyhow::anyhow!(error.message))?;
Ok(result
.summary
.unwrap_or_else(|| "No workspace file changes needed restoring.".to_string()))
@@ -1,5 +1,5 @@
command: /cache wat
is_error: true
message:
Error: Unknown /cache argument `wat`. Usage: /cache [count|inspect [--verbose|--json]|stats|zones|warmup]
Error: Unknown /cache argument `wat`. Usage: /cache [count|calls [count]|inspect [--verbose|--json]|stats|zones|warmup]
action: <none>
@@ -49,7 +49,7 @@
"is_error": false
},
"/cache nonsense": {
"message": "Error: Unknown /cache argument `nonsense`. Usage: /cache [count|inspect [--verbose|--json]|stats|zones|warmup]",
"message": "Error: Unknown /cache argument `nonsense`. Usage: /cache [count|calls [count]|inspect [--verbose|--json]|stats|zones|warmup]",
"action": null,
"is_error": true
},
@@ -28,7 +28,7 @@ show_in_empty_discovery: false
---
name: cache
aliases: []
usage: /cache [count|inspect|stats|zones|warmup]
usage: /cache [count|calls [count]|inspect|stats|zones|warmup]
description_en: Show cache hit/miss stats for recent turns
palette_en: Show cache hit/miss stats for recent turns
requires_argument: true
+2 -2
View File
@@ -80,10 +80,10 @@ pub(in crate::commands) fn host_result(result: SessionCommandResult) -> CommandR
SessionAction::OpenExternalUrl { url, label } => AppAction::OpenExternalUrl { url, label },
SessionAction::RemoteControl(action) => AppAction::RemoteControl(match action {
codewhale_command_contract::outcome::SessionRemoteControlAction::Start => {
crate::remote_control::RemoteControlAction::Start
codewhale_app_server::remote_control::RemoteControlAction::Start
}
codewhale_command_contract::outcome::SessionRemoteControlAction::Stop => {
crate::remote_control::RemoteControlAction::Stop
codewhale_app_server::remote_control::RemoteControlAction::Stop
}
}),
SessionAction::NewSession { sync, force } => AppAction::NewSession { sync, force },
+40 -1147
View File
File diff suppressed because it is too large. Load diff
@@ -71,58 +71,6 @@ fn sessions_archive_cli_keeps_legacy_listing_and_export_options() {
);
}
#[test]
fn headless_consultant_authority_overrides_network_allow_and_disables_web_search() {
let config = {
let __cw_config_init_0_0 = Some(codewhale_config::NetworkPolicyToml {
default: "allow".to_string(),
audit: false,
..codewhale_config::NetworkPolicyToml::default()
});
let mut __cw_config_init_0_value: Config = Config::default();
__cw_config_init_0_value.network = __cw_config_init_0_0;
__cw_config_init_0_value
};
let authority = codewhale_engine::tools::spec::ToolAuthorityEnvelope {
schema_version: 1,
owner: "consultant-1".to_string(),
authority: codewhale_engine::tools::spec::ToolMutationAuthority::ReadOnly,
network_access: Some(false),
shell: codewhale_engine::tools::spec::ToolShellAuthority::None,
verification: codewhale_engine::tools::spec::ToolVerificationAuthority::None,
writable_roots: Vec::new(),
writable_files: Vec::new(),
coordination_contracts: Vec::new(),
}
.normalized()
.expect("Consultant authority");
let policy = exec_network_policy(&config, authority.network_access)
.expect("explicit network=false always installs a policy");
assert_eq!(
policy.evaluate("example.com", "web_search"),
codewhale_net::network_policy::Decision::Deny,
"the permissive user config must not widen Consultant network authority"
);
let mut features = codewhale_runtime::features::Features::default();
features.enable(codewhale_runtime::features::Feature::ShellTool);
features.enable(codewhale_runtime::features::Feature::WebSearch);
apply_fleet_engine_feature_caps(
&mut features,
true,
authority.network_access,
authority.shell,
);
assert!(!features.enabled(codewhale_runtime::features::Feature::WebSearch));
assert!(!features.enabled(codewhale_runtime::features::Feature::ShellTool));
let worker_policy = exec_network_policy(&config, Some(true)).expect("configured policy");
assert_eq!(
worker_policy.evaluate("example.com", "web_search"),
codewhale_net::network_policy::Decision::Allow,
"a network-capable role keeps the configured policy"
);
}
#[test]
fn hidden_remote_control_flag_starts_the_interactive_handoff() {
let cli = parse_cli(&["codewhale-tui", "--remote-control"]);
@@ -164,40 +112,6 @@ fn plugin_registry_discovery_is_route_independent_and_read_only() {
}
}
fn custom_exec_config(active: &str) -> Config {
let mut custom = std::collections::HashMap::new();
for (name, base_url, model) in [
(
"custom-a",
"http://127.0.0.1:18181/v1",
codewhale_config::catalog::reviewed::constants::ZAI_GLM_5_2_MODEL,
),
("custom-b", "http://127.0.0.1:18182/v1", "model-b"),
] {
custom.insert(
name.to_string(),
codewhale_runtime::config::ProviderConfig {
kind: Some("openai-compatible".to_string()),
base_url: Some(base_url.to_string()),
model: Some(model.to_string()),
api_key: Some("local-test-key".to_string()),
..Default::default()
},
);
}
{
let __cw_config_init_1_0 = Some(active.to_string());
let __cw_config_init_1_1 = Some(codewhale_runtime::config::ProvidersConfig {
custom,
..Default::default()
});
let mut __cw_config_init_1_value: Config = Default::default();
__cw_config_init_1_value.provider = __cw_config_init_1_0;
__cw_config_init_1_value.providers = __cw_config_init_1_1;
__cw_config_init_1_value
}
}
#[test]
fn doctor_json_surfaces_keep_exact_named_custom_provider() {
let config = custom_exec_config("custom-a");
@@ -800,193 +714,6 @@ fn selected_fleet_operator_load_error_redacts_paths_excerpts_and_opaque_name() {
assert!(message.chars().count() <= 700, "{message}");
}
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn explicit_exec_model_routes_to_unique_authenticated_provider_candidate() {
let _env_lock = crate::test_support::lock_test_env();
let _zai = crate::test_support::EnvVarGuard::set("ZAI_API_KEY", "zai-key");
let _openrouter = crate::test_support::EnvVarGuard::remove("OPENROUTER_API_KEY");
let config = {
let __cw_config_init_11_0 = Some("deepseek".to_string());
let __cw_config_init_11_1 =
Some(codewhale_config::descriptors::defaults::DEFAULT_TEXT_MODEL.to_string());
let mut __cw_config_init_11_value: Config = Default::default();
__cw_config_init_11_value.provider = __cw_config_init_11_0;
__cw_config_init_11_value.default_text_model = __cw_config_init_11_1;
__cw_config_init_11_value
};
let route = resolve_cli_auto_route(
&config,
codewhale_config::catalog::reviewed::constants::ZAI_GLM_5_2_MODEL,
"pong",
None,
)
.await
.expect("explicit GLM should route to the configured Z.ai provider");
assert_eq!(route.provider.provider, codewhale_config::ProviderKind::Zai);
assert_eq!(
route.model,
codewhale_config::catalog::reviewed::constants::ZAI_GLM_5_2_MODEL
);
assert!(!route.auto_model);
}
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn explicit_exec_model_reports_ambiguous_authenticated_provider_candidates() {
let _env_lock = crate::test_support::lock_test_env();
let _zai = crate::test_support::EnvVarGuard::set("ZAI_API_KEY", "zai-key");
let _openrouter = crate::test_support::EnvVarGuard::set("OPENROUTER_API_KEY", "or-key");
let config = {
let __cw_config_init_12_0 = Some("deepseek".to_string());
let __cw_config_init_12_1 =
Some(codewhale_config::descriptors::defaults::DEFAULT_TEXT_MODEL.to_string());
let mut __cw_config_init_12_value: Config = Default::default();
__cw_config_init_12_value.provider = __cw_config_init_12_0;
__cw_config_init_12_value.default_text_model = __cw_config_init_12_1;
__cw_config_init_12_value
};
let err = resolve_cli_auto_route(
&config,
codewhale_config::catalog::reviewed::constants::ZAI_GLM_5_2_MODEL,
"pong",
None,
)
.await
.expect_err("ambiguous GLM route should ask for an explicit provider");
let message = err.to_string();
assert!(message.contains("model `GLM-5.2` is available"));
assert!(message.contains("openrouter"));
assert!(message.contains("zai"));
assert!(message.contains("--provider"));
assert!(message.contains("/provider"));
assert!(message.contains("/model"));
assert!(message.contains("/setup"));
}
#[tokio::test]
async fn cli_auto_model_honors_a_fixed_reasoning_preference() {
let config = {
let __cw_config_init_13_0 = Some("vllm".to_string());
let __cw_config_init_13_1 = Some("low".to_string());
let __cw_config_init_13_2 = Some(codewhale_runtime::config::ProvidersConfig {
vllm: codewhale_runtime::config::ProviderConfig {
base_url: Some("http://127.0.0.1:18190/v1".to_string()),
model: Some("local-auto-model".to_string()),
..Default::default()
},
..Default::default()
});
let mut __cw_config_init_13_value: Config = Default::default();
__cw_config_init_13_value.provider = __cw_config_init_13_0;
__cw_config_init_13_value.reasoning_effort = __cw_config_init_13_1;
__cw_config_init_13_value.providers = __cw_config_init_13_2;
__cw_config_init_13_value
};
let route = resolve_cli_auto_route(&config, "auto", "debug a failing test", None)
.await
.expect("Auto model route");
assert!(route.auto_model);
assert_eq!(
route.reasoning_effort,
Some(codewhale_engine::reasoning_preference::ReasoningEffort::Low)
);
assert!(
!route.auto_controls_reasoning,
"a fixed saved tier must not be replaced per prompt"
);
}
#[test]
fn cli_route_execution_config_stamps_routed_model_into_provider_slot() {
let mut providers = codewhale_runtime::config::ProvidersConfig::default();
providers.deepseek.model = Some("deepseek-v4-pro".to_string());
let config = {
let __cw_config_init_14_0 = Some("deepseek".to_string());
let __cw_config_init_14_1 = Some(providers);
let mut __cw_config_init_14_value: Config = Default::default();
__cw_config_init_14_value.provider = __cw_config_init_14_0;
__cw_config_init_14_value.providers = __cw_config_init_14_1;
__cw_config_init_14_value
};
let route = CliAutoRoute {
provider: Config::default()
.test_identity_for_kind(codewhale_config::ProviderKind::Deepseek),
model: "deepseek-v4-flash".to_string(),
reasoning_effort: None,
auto_controls_reasoning: true,
auto_model: true,
};
let execution_config = config_for_cli_route(&config, &route).expect("admitted execution route");
assert_eq!(execution_config.default_model(), "deepseek-v4-flash");
assert_eq!(
execution_config
.provider_config_for(
&execution_config.test_identity_for_kind(codewhale_config::ProviderKind::Deepseek)
)
.and_then(|entry| entry.model.as_deref()),
Some("deepseek-v4-flash")
);
}
#[test]
fn cli_route_execution_config_preserves_the_legacy_literal_custom_route() {
let _lock = crate::test_support::lock_test_env();
let _source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
let _cli_key = crate::test_support::EnvVarGuard::remove("CODEWHALE_CLI_API_KEY");
let config = {
let __cw_config_init_15_0 = Some("custom".to_string());
let __cw_config_init_15_1 = Some("legacy-model".to_string());
let mut __cw_config_init_15_value: Config = Default::default();
__cw_config_init_15_value.provider = __cw_config_init_15_0;
__cw_config_init_15_value.default_text_model = __cw_config_init_15_1;
__cw_config_init_15_value
}
.with_legacy_root(
Some("legacy-root-key".to_string()),
Some("http://127.0.0.1:18183/v1".to_string()),
);
let route = CliAutoRoute {
provider: config
.active_provider_identity()
.expect("captured literal root identity"),
model: "routed-legacy-model".to_string(),
reasoning_effort: None,
auto_controls_reasoning: false,
auto_model: false,
};
let execution = config_for_cli_route(&config, &route).expect("admitted execution route");
// The literal route's top-level fields live in `[providers.custom]`
// (#6394), and the routed model lands there too.
assert!(execution.selects_literal_custom_provider());
assert_eq!(execution.provider.as_deref(), Some("custom"));
assert_eq!(execution.default_model(), "routed-legacy-model");
assert_eq!(
execution.active_route_base_url(),
"http://127.0.0.1:18183/v1"
);
assert_eq!(execution.active_route_api_key().unwrap(), "legacy-root-key");
for _ in 0..2 {
let identity = execution
.resolve_provider_identity("custom")
.expect("legacy identity remains repeatedly resolvable");
assert_eq!(identity.key.as_str(), "custom");
}
let client = codewhale_engine::client::CodewhaleClient::new(&execution)
.expect("legacy execution client");
assert_eq!(client.base_url(), "http://127.0.0.1:18183/v1");
}
/// #6510: only a flag that grants tool authority opens a tool surface.
/// Limits, prompt/hook opt-ins and the output format keep plain exec a
/// zero-tool one-shot; `--max-turns 1` used to make it a tool agent.
@@ -1540,32 +1267,6 @@ fn exec_model_reads_wait_for_foreign_test_env_overrides_to_restore() {
assert_eq!(observed, expected_after_restore);
}
#[tokio::test]
async fn forced_exec_route_keeps_custom_provider_when_model_matches_builtin_catalog() {
let config = custom_exec_config("custom-a");
let route = resolve_cli_exec_route(
&config,
codewhale_config::catalog::reviewed::constants::ZAI_GLM_5_2_MODEL,
"audit",
true,
None,
)
.await
.expect("forced route");
let execution = config_for_cli_route(&config, &route).expect("admitted execution route");
assert_eq!(
route.provider.provider,
codewhale_config::ProviderKind::Custom
);
assert_eq!(
route.model,
codewhale_config::catalog::reviewed::constants::ZAI_GLM_5_2_MODEL
);
assert_eq!(execution.provider.as_deref(), Some("custom-a"));
}
#[tokio::test]
async fn no_flag_exec_keeps_configured_named_custom_route_for_matching_builtin_model() {
let mut config = custom_exec_config("custom-a");
@@ -1034,156 +1034,6 @@ fn oversized_review_suggestion_degrades_to_prose() {
);
}
#[tokio::test]
async fn configured_workflow_default_keeps_named_custom_route() {
let config = custom_exec_config("custom-a");
let model = config.default_model();
let route = resolve_cli_exec_route(
&config,
&model,
"Run a checked-in Workflow through the host runtime",
true,
None,
)
.await
.expect("configured workflow route");
let execution = config_for_cli_route(&config, &route).expect("admitted execution route");
assert_eq!(
route.provider.provider,
codewhale_config::ProviderKind::Custom
);
assert_eq!(
execution.active_provider_identity().unwrap().key.as_str(),
"custom-a"
);
assert_eq!(
execution.active_route_base_url(),
"http://127.0.0.1:18181/v1"
);
let client =
codewhale_engine::client::CodewhaleClient::new(&execution).expect("workflow client");
assert_eq!(client.base_url(), "http://127.0.0.1:18181/v1");
}
#[test]
fn exec_json_receipts_keep_exact_named_custom_provider() {
let config = custom_exec_config("custom-a");
let identity = config.active_provider_identity().unwrap();
let provider = identity.key.as_str();
// #6510: plain exec is an Engine turn now; its `--json` receipt keeps
// the documented one-shot fields (docs/LIVE_SMOKE.md step 5).
let mut one_shot = ExecSummary {
mode: "one-shot".to_string(),
provider: provider.to_string(),
model: "model-a".to_string(),
output: "done".to_string(),
status: Some("completed".to_string()),
..ExecSummary::default()
};
one_shot.record_one_shot_outcome(Some(codewhale_models::Usage {
input_tokens: 12,
output_tokens: 3,
..Default::default()
}));
let one_shot = serde_json::to_value(&one_shot).expect("one-shot receipt");
assert_eq!(one_shot["mode"], "one-shot");
assert_eq!(one_shot["provider"], "custom-a");
assert_eq!(one_shot["model"], "model-a");
assert_eq!(one_shot["output"], "done");
assert_eq!(one_shot["success"], true);
assert_eq!(one_shot["usage"]["input_tokens"], 12);
assert_eq!(one_shot["usage"]["output_tokens"], 3);
let mut failed = ExecSummary {
mode: "one-shot".to_string(),
provider: provider.to_string(),
model: "model-a".to_string(),
status: Some("failed".to_string()),
error: Some("Model response incomplete".to_string()),
..ExecSummary::default()
};
failed.record_one_shot_outcome(None);
let failed = serde_json::to_value(&failed).expect("failed one-shot receipt");
assert_eq!(failed["success"], false);
assert!(
failed.get("usage").is_none(),
"no usage is never zero usage"
);
let agent = serde_json::to_value(ExecSummary {
mode: "agent".to_string(),
provider: provider.to_string(),
model: "model-a".to_string(),
..ExecSummary::default()
})
.expect("agent exec JSON receipt");
assert_eq!(agent["provider"], "custom-a");
assert!(
agent.get("success").is_none() && agent.get("usage").is_none(),
"agent receipts keep their pre-#6510 shape"
);
let serialized = serde_json::to_string(&agent).expect("serialize receipt");
assert!(!serialized.contains("127.0.0.1"));
assert!(!serialized.contains("local-test-key"));
}
#[test]
fn exec_stream_provider_pair_preserves_named_literal_and_root_custom_provenance() {
let mut config = Config::default();
for key in ["lm-studio", "custom"] {
config
.providers
.get_or_insert_with(Default::default)
.custom
.insert(
key.to_string(),
codewhale_runtime::config::ProviderConfig {
kind: Some("openai-compatible".to_string()),
base_url: Some("http://localhost:1234/v1".to_string()),
..Default::default()
},
);
}
let named = config
.resolve_provider_identity("lm-studio")
.expect("exact named table");
let literal = config
.resolve_provider_identity("custom")
.expect("exact literal table");
let root_config = {
let __cw_config_init_1_0 = Some("custom".to_string());
let mut __cw_config_init_1_value: Config = Config::default();
__cw_config_init_1_value.provider = __cw_config_init_1_0;
__cw_config_init_1_value
}
.with_legacy_root(None, Some("http://localhost:1234/v1".to_string()));
let root = root_config
.active_provider_identity()
.expect("captured root migration");
let built_in = Config::default()
.active_provider_identity()
.expect("built-in fixture");
assert_eq!(
exec_stream_provider_route(&named),
("custom".to_string(), Some("lm-studio".to_string()))
);
assert_eq!(
exec_stream_provider_route(&literal),
("custom".to_string(), Some("custom".to_string()))
);
assert_eq!(
exec_stream_provider_route(&root),
("custom".to_string(), None)
);
assert_eq!(
exec_stream_provider_route(&built_in),
("deepseek".to_string(), None)
);
}
#[test]
fn resumed_exec_persistence_updates_provider_and_model_as_one_route() {
let saved_a = saved_exec_session(
@@ -1217,33 +1067,6 @@ fn resumed_exec_persistence_updates_provider_and_model_as_one_route() {
assert_eq!(resumed_model, "model-b");
}
#[test]
fn exec_persistence_omits_id_for_legacy_root_custom_route() {
let mut saved = session_manager::create_saved_session_with_mode(
&[],
"legacy-root-model",
Path::new("/tmp/exec-root"),
0,
None,
Some("exec"),
);
stamp_exec_session_metadata(
&mut saved,
"legacy-root-model",
codewhale_config::ProviderKind::Custom.as_str(),
None,
Path::new("/tmp/exec-root"),
);
assert_eq!(saved.metadata.model_provider, "custom");
assert_eq!(saved.metadata.model_provider_id, None);
assert!(
!serde_json::to_string(&saved)
.expect("serialize exec session")
.contains("model_provider_id")
);
}
#[test]
fn exec_parses_reasoning_effort_flag_alongside_provider() {
let cli = parse_cli(&[
@@ -1285,80 +1108,6 @@ fn cli_reasoning_effort_normalizes_aliases_and_rejects_typos() {
assert!(normalize_cli_reasoning_effort("expensive").is_err());
}
#[test]
fn cli_auto_resolves_to_the_declared_default_before_k3_route_normalization() {
let config = {
let __cw_config_init_2_0 = Some("moonshot".to_string());
let __cw_config_init_2_1 = Some(codewhale_runtime::config::ProvidersConfig {
moonshot: codewhale_runtime::config::ProviderConfig {
base_url: Some(
codewhale_config::descriptors::defaults::DEFAULT_KIMI_CODE_BASE_URL.to_string(),
),
model: Some(
codewhale_config::catalog::reviewed::constants::KIMI_CODE_K3_MODEL.to_string(),
),
..Default::default()
},
..Default::default()
});
let mut __cw_config_init_2_value: Config = Default::default();
__cw_config_init_2_value.provider = __cw_config_init_2_0;
__cw_config_init_2_value.providers = __cw_config_init_2_1;
__cw_config_init_2_value
};
// #6290 rework: Auto no longer classifies the prompt. Any wording
// resolves the declared policy tier, normalized for the K3 route.
assert_eq!(
cli_reasoning_effort_value_for_prompt(
&config,
codewhale_config::catalog::reviewed::constants::KIMI_CODE_K3_MODEL,
codewhale_engine::reasoning_preference::ReasoningEffort::Auto,
)
.as_deref(),
Some("high"),
"Auto resolves the declared default, not a classification"
);
assert_eq!(
cli_reasoning_effort_value_for_prompt(
&config,
codewhale_config::catalog::reviewed::constants::KIMI_CODE_K3_MODEL,
codewhale_engine::reasoning_preference::ReasoningEffort::Off,
)
.as_deref(),
Some("low"),
"membership K3 still applies its exact-route always-thinking floor"
);
}
#[test]
fn cli_route_tracks_auto_reasoning_independently_from_auto_model() {
use codewhale_engine::reasoning_preference::ReasoningEffort;
let fixed_model_auto_reasoning = CliAutoRoute {
provider: Config::default()
.test_identity_for_kind(codewhale_config::ProviderKind::Deepseek),
model: codewhale_config::descriptors::defaults::DEFAULT_TEXT_MODEL.to_string(),
reasoning_effort: Some(ReasoningEffort::Auto),
auto_controls_reasoning: true,
auto_model: false,
};
let auto_model_fixed_reasoning = CliAutoRoute {
provider: Config::default()
.test_identity_for_kind(codewhale_config::ProviderKind::OpenaiCodex),
model: codewhale_config::descriptors::defaults::DEFAULT_OPENAI_CODEX_MODEL.to_string(),
reasoning_effort: Some(ReasoningEffort::High),
auto_controls_reasoning: false,
auto_model: true,
};
assert!(fixed_model_auto_reasoning.auto_controls_reasoning);
assert!(!fixed_model_auto_reasoning.auto_model);
assert!(!auto_model_fixed_reasoning.auto_controls_reasoning);
assert!(auto_model_fixed_reasoning.auto_model);
}
#[test]
fn saved_reasoning_preference_overrides_config_for_non_tui_runtimes() {
let mut config = {
@@ -1383,34 +1132,6 @@ fn saved_reasoning_preference_overrides_config_for_non_tui_runtimes() {
assert!(config.reasoning_effort_is_explicit());
}
/// `run_exec_agent` must hand the engine a concrete tier, never the literal
/// `"auto"` sentinel, for a fixed-model Auto launch.
#[test]
fn fixed_model_exec_auto_resolves_to_a_concrete_tier_not_the_auto_sentinel() {
let config = {
let __cw_config_init_5_0 = Some("zai".to_string());
let mut __cw_config_init_5_value: Config = Default::default();
__cw_config_init_5_value.provider = __cw_config_init_5_0;
__cw_config_init_5_value
};
let resolved = cli_reasoning_effort_value_for_prompt(
&config,
codewhale_config::catalog::reviewed::constants::ZAI_GLM_5_2_MODEL,
codewhale_engine::reasoning_preference::ReasoningEffort::Auto,
)
.expect("Auto must resolve to a concrete tier");
assert_ne!(
resolved, "auto",
"the literal auto sentinel must never reach a provider"
);
assert!(
matches!(resolved.as_str(), "off" | "low" | "medium" | "high" | "max"),
"unexpected resolved tier: {resolved}"
);
}
#[test]
fn exec_accepts_resume_session_flags_for_harnesses() {
let cli = parse_cli(&[
@@ -1491,16 +1212,6 @@ fn exec_rejects_zero_max_tool_calls() {
assert_eq!(err.kind(), clap::error::ErrorKind::ValueValidation);
}
#[test]
fn fleet_tool_authority_cannot_cross_an_exec_resume_boundary() {
assert!(validate_exec_tool_authority_resume(None, true).is_ok());
assert!(validate_exec_tool_authority_resume(Some("{}"), false).is_ok());
let error = validate_exec_tool_authority_resume(Some("{}"), true)
.expect_err("authority must remain bound to its fresh Fleet launch")
.to_string();
assert!(error.contains("cannot be combined with exec --resume"));
}
#[test]
fn exec_auto_does_not_authorize_sandbox_elevation() {
let cli = parse_cli(&["codewhale", "exec", "--auto", "run it"]);
@@ -1548,21 +1259,6 @@ fn exec_explicit_sandbox_elevation_opt_ins_authorize_retry() {
));
}
#[test]
fn exec_sandbox_denial_stream_event_is_typed() {
let event = ExecStreamEvent::SandboxDenied {
tool_id: "call_1".to_string(),
tool_name: "exec_shell".to_string(),
reason: "write blocked".to_string(),
outcome: "approval_required".to_string(),
};
let value: serde_json::Value =
serde_json::from_str(&serde_json::to_string(&event).expect("serializes"))
.expect("valid json");
assert_eq!(value["type"], "sandbox_denied");
assert_eq!(value["outcome"], "approval_required");
}
#[test]
fn exec_help_discloses_full_access_and_session_persistence() {
let _env_lock = crate::test_support::lock_test_env();
File diff suppressed because it is too large. Load diff
Loaded 100 of 127 files, more files were not shown because too many files have changed in this diff. Show more