sdk, agent: review fixes (T18R)
- The serve loop ends the process after EOF or exit_after_response, also with background tasks or listeners. - One schema generator per direction for the whole document, so types with the same name get distinct names. - The agent gives the complete error (code, message, data) to the model, writes SKIP only after a successful request, caps tool output at 64 KiB, writes each history line in one write, and skips a damaged line. - Endpoints skip blank and non-RPC lines. client_rs does not panic. - #[must_use] on builders and the lock guard; #[schemars] attributes and duplicate hooks are compile errors; raw identifiers; -32000 for local SDK errors; x-sausage-params only for methods with params.
This commit is contained in:
@@ -10,7 +10,10 @@ pub fn is_too_large(error: &str) -> bool {
|
|||||||
else {
|
else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
if !matches!(status.split_whitespace().next(), Some("400" | "413")) {
|
if status.split_whitespace().next() == Some("413") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if status.split_whitespace().next() != Some("400") {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
let body = body.to_ascii_lowercase();
|
let body = body.to_ascii_lowercase();
|
||||||
@@ -38,6 +41,8 @@ mod tests {
|
|||||||
fn detects_context_size_errors_only_for_http_400_or_413() {
|
fn detects_context_size_errors_only_for_http_400_or_413() {
|
||||||
assert!(is_too_large("HTTP 400: context_length_exceeded"));
|
assert!(is_too_large("HTTP 400: context_length_exceeded"));
|
||||||
assert!(is_too_large("HTTP 413: prompt is too long"));
|
assert!(is_too_large("HTTP 413: prompt is too long"));
|
||||||
|
assert!(is_too_large("HTTP 413: {\"type\":\"request_too_large\"}"));
|
||||||
|
assert!(is_too_large("HTTP 413: {}"));
|
||||||
assert!(is_too_large("HTTP 400: maximum context length"));
|
assert!(is_too_large("HTTP 400: maximum context length"));
|
||||||
assert!(is_too_large("HTTP 400: request is too large"));
|
assert!(is_too_large("HTTP 400: request is too large"));
|
||||||
assert!(!is_too_large("HTTP 500: prompt is too long"));
|
assert!(!is_too_large("HTTP 500: prompt is too long"));
|
||||||
|
|||||||
+14
-3
@@ -122,6 +122,9 @@ pub enum Entry {
|
|||||||
Opaque {
|
Opaque {
|
||||||
/// The API shape that owns this entry.
|
/// The API shape that owns this entry.
|
||||||
api: String,
|
api: String,
|
||||||
|
/// The model that produced this item. Legacy items without a model are not replayed.
|
||||||
|
#[serde(default)]
|
||||||
|
model: String,
|
||||||
/// The original provider object.
|
/// The original provider object.
|
||||||
item: Value,
|
item: Value,
|
||||||
},
|
},
|
||||||
@@ -244,7 +247,7 @@ async fn openai(
|
|||||||
Entry::ToolResult { id, output } => {
|
Entry::ToolResult { id, output } => {
|
||||||
input.push(json!({"type": "function_call_output", "call_id": id, "output": output}))
|
input.push(json!({"type": "function_call_output", "call_id": id, "output": output}))
|
||||||
}
|
}
|
||||||
Entry::Opaque { api, item } if api == "openai-responses" => input.push(item.clone()),
|
Entry::Opaque { api, model, item } if api == "openai-responses" && model == &cfg.model => input.push(item.clone()),
|
||||||
Entry::Opaque { .. } => {}
|
Entry::Opaque { .. } => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -299,12 +302,17 @@ async fn openai(
|
|||||||
}),
|
}),
|
||||||
Some("reasoning") => out.push(Entry::Opaque {
|
Some("reasoning") => out.push(Entry::Opaque {
|
||||||
api: "openai-responses".into(),
|
api: "openai-responses".into(),
|
||||||
|
model: cfg.model.clone(),
|
||||||
item,
|
item,
|
||||||
}),
|
}),
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if resp["status"] == "incomplete" && out.is_empty() {
|
if resp["status"] == "incomplete"
|
||||||
|
&& !out
|
||||||
|
.iter()
|
||||||
|
.any(|entry| matches!(entry, Entry::Assistant { text, .. } if !text.is_empty()))
|
||||||
|
{
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"incomplete response: {}",
|
"incomplete response: {}",
|
||||||
resp["incomplete_details"]
|
resp["incomplete_details"]
|
||||||
@@ -350,7 +358,9 @@ async fn anthropic(
|
|||||||
json!({"type": "tool_result", "tool_use_id": id, "content": output}),
|
json!({"type": "tool_result", "tool_use_id": id, "content": output}),
|
||||||
),
|
),
|
||||||
// Thinking blocks go back unchanged (with their signature): required next to tool use.
|
// Thinking blocks go back unchanged (with their signature): required next to tool use.
|
||||||
Entry::Opaque { api, item } if api == "anthropic-messages" && thinking => {
|
Entry::Opaque { api, model, item }
|
||||||
|
if api == "anthropic-messages" && model == &cfg.model && thinking =>
|
||||||
|
{
|
||||||
push("assistant", item.clone())
|
push("assistant", item.clone())
|
||||||
}
|
}
|
||||||
Entry::Opaque { .. } => {}
|
Entry::Opaque { .. } => {}
|
||||||
@@ -405,6 +415,7 @@ async fn anthropic(
|
|||||||
}),
|
}),
|
||||||
Some("thinking") | Some("redacted_thinking") => out.push(Entry::Opaque {
|
Some("thinking") | Some("redacted_thinking") => out.push(Entry::Opaque {
|
||||||
api: "anthropic-messages".into(),
|
api: "anthropic-messages".into(),
|
||||||
|
model: cfg.model.clone(),
|
||||||
item: block,
|
item: block,
|
||||||
}),
|
}),
|
||||||
_ => {}
|
_ => {}
|
||||||
|
|||||||
+42
-11
@@ -33,7 +33,7 @@ impl Agent {
|
|||||||
let _guard = lock(HISTORY).await?;
|
let _guard = lock(HISTORY).await?;
|
||||||
std::fs::create_dir_all("/state/agent")?;
|
std::fs::create_dir_all("/state/agent")?;
|
||||||
let cfg: Config = serde_json::from_slice(&std::fs::read("/config/agent.json")?)?;
|
let cfg: Config = serde_json::from_slice(&std::fs::read("/config/agent.json")?)?;
|
||||||
let mut entries = load_history()?;
|
let mut entries = load_history(HISTORY)?;
|
||||||
repair_tools(&mut entries)?;
|
repair_tools(&mut entries)?;
|
||||||
let current = entries.len();
|
let current = entries.len();
|
||||||
append(&mut entries, Entry::User { text: p.text })?;
|
append(&mut entries, Entry::User { text: p.text })?;
|
||||||
@@ -52,12 +52,12 @@ impl Agent {
|
|||||||
&& let Some(next) = next_turn_start(&entries, start + 1, current)
|
&& let Some(next) = next_turn_start(&entries, start + 1, current)
|
||||||
{
|
{
|
||||||
start = next;
|
start = next;
|
||||||
std::fs::write(SKIP, start.to_string())?;
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
return Err(Error::new(-32603, error));
|
return Err(Error::new(-32603, error));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
std::fs::write(SKIP, start.to_string())?;
|
||||||
let has_tools = response
|
let has_tools = response
|
||||||
.iter()
|
.iter()
|
||||||
.any(|entry| matches!(entry, Entry::ToolCall { .. }));
|
.any(|entry| matches!(entry, Entry::ToolCall { .. }));
|
||||||
@@ -70,7 +70,12 @@ impl Agent {
|
|||||||
{
|
{
|
||||||
ctx.notify("text", json!({"text":text}))?
|
ctx.notify("text", json!({"text":text}))?
|
||||||
}
|
}
|
||||||
Entry::Assistant { text, .. } => final_text.push_str(text),
|
Entry::Assistant { text, .. } => {
|
||||||
|
if !final_text.is_empty() && !text.is_empty() {
|
||||||
|
final_text.push_str("\n\n");
|
||||||
|
}
|
||||||
|
final_text.push_str(text);
|
||||||
|
}
|
||||||
Entry::ToolCall {
|
Entry::ToolCall {
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
@@ -87,14 +92,14 @@ impl Agent {
|
|||||||
let output = match serde_json::from_str(&arguments) {
|
let output = match serde_json::from_str(&arguments) {
|
||||||
Ok(args) => tools::run(&name, &args)
|
Ok(args) => tools::run(&name, &args)
|
||||||
.await
|
.await
|
||||||
.unwrap_or_else(|error| json!({"error":error.to_string()})),
|
.unwrap_or_else(|error| json!({"error":error})),
|
||||||
Err(error) => json!({"error":format!("arguments are not valid JSON: {error}")}),
|
Err(error) => json!({"error":format!("arguments are not valid JSON: {error}")}),
|
||||||
};
|
};
|
||||||
append(
|
append(
|
||||||
&mut entries,
|
&mut entries,
|
||||||
Entry::ToolResult {
|
Entry::ToolResult {
|
||||||
id,
|
id,
|
||||||
output: output.to_string(),
|
output: tools::limit_output(output.to_string()),
|
||||||
},
|
},
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
@@ -103,12 +108,18 @@ impl Agent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Read persisted entries in their original order. A new world has no history file.
|
/// Read persisted entries in their original order. A new world has no history file.
|
||||||
fn load_history() -> Result<Vec<Entry>> {
|
fn load_history(path: &str) -> Result<Vec<Entry>> {
|
||||||
match std::fs::read_to_string(HISTORY) {
|
match std::fs::read_to_string(path) {
|
||||||
Ok(text) => text
|
Ok(text) => Ok(text
|
||||||
.lines()
|
.lines()
|
||||||
.map(|line| serde_json::from_str(line).map_err(Into::into))
|
.filter_map(|line| match serde_json::from_str(line) {
|
||||||
.collect(),
|
Ok(entry) => Some(entry),
|
||||||
|
Err(error) => {
|
||||||
|
eprintln!("skip damaged history line: {error}");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()),
|
||||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
|
||||||
Err(error) => Err(error.into()),
|
Err(error) => Err(error.into()),
|
||||||
}
|
}
|
||||||
@@ -119,7 +130,7 @@ fn append(history: &mut Vec<Entry>, entry: Entry) -> Result<()> {
|
|||||||
.create(true)
|
.create(true)
|
||||||
.append(true)
|
.append(true)
|
||||||
.open(HISTORY)?;
|
.open(HISTORY)?;
|
||||||
writeln!(file, "{}", serde_json::to_string(&entry)?)?;
|
file.write_all(format!("{}\n", serde_json::to_string(&entry)?).as_bytes())?;
|
||||||
history.push(entry);
|
history.push(entry);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -154,3 +165,23 @@ fn repair_tools(history: &mut Vec<Entry>) -> Result<()> {
|
|||||||
fn main() {
|
fn main() {
|
||||||
sausage_plugin::run(Agent);
|
sausage_plugin::run(Agent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
#[test]
|
||||||
|
fn damaged_history_keeps_valid_entries() {
|
||||||
|
let path = if cfg!(target_os = "wasi") {
|
||||||
|
std::path::PathBuf::from("/history-test.jsonl")
|
||||||
|
} else {
|
||||||
|
std::env::temp_dir().join(format!("sausage-history-{}", std::process::id()))
|
||||||
|
};
|
||||||
|
std::fs::write(
|
||||||
|
&path,
|
||||||
|
"{\"kind\":\"user\",\"text\":\"preserved\"}\n{\"kind\":",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let history = super::load_history(path.to_str().unwrap()).unwrap();
|
||||||
|
assert_eq!(history.len(), 1);
|
||||||
|
std::fs::remove_file(path).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,5 +3,6 @@ You are the Sausage agent. Answer the person. Use tools to inspect or change the
|
|||||||
Read the contract at `/proc/core/PROTOCOL.md`. The Rust plugin SDK and its documentation are at `/plugins/cargo/sdk/`.
|
Read the contract at `/proc/core/PROTOCOL.md`. The Rust plugin SDK and its documentation are at `/plugins/cargo/sdk/`.
|
||||||
Build a plugin with `cargo.build`. Activate its WASM file with `core.activate`.
|
Build a plugin with `cargo.build`. Activate its WASM file with `core.activate`.
|
||||||
Your own source is at `/plugins/agent/source/`. You can change and rebuild it like any other plugin.
|
Your own source is at `/plugins/agent/source/`. You can change and rebuild it like any other plugin.
|
||||||
|
Never call `agent.chat`. It would wait on the history lock that your current turn holds.
|
||||||
Write logs to stderr. Use call results and notifications for communication.
|
Write logs to stderr. Use call results and notifications for communication.
|
||||||
The interface check does not cover persistent state. Preserve state compatibility when you change a plugin.
|
The interface check does not cover persistent state. Preserve state compatibility when you change a plugin.
|
||||||
|
|||||||
+54
-5
@@ -4,12 +4,29 @@ use crate::llm::Tool;
|
|||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
use std::io;
|
use std::io;
|
||||||
|
|
||||||
|
/// Maximum UTF-8 bytes in a serialized tool result, including its truncation marker.
|
||||||
|
pub const MAX_OUTPUT_BYTES: usize = 65_536;
|
||||||
|
|
||||||
|
/// Bound every tool result before it enters history or a provider request.
|
||||||
|
pub fn limit_output(mut output: String) -> String {
|
||||||
|
const MARKER: &str = "\n[tool output truncated at 65536 bytes]";
|
||||||
|
if output.len() > MAX_OUTPUT_BYTES {
|
||||||
|
let mut end = MAX_OUTPUT_BYTES - MARKER.len();
|
||||||
|
while !output.is_char_boundary(end) {
|
||||||
|
end -= 1;
|
||||||
|
}
|
||||||
|
output.truncate(end);
|
||||||
|
output.push_str(MARKER);
|
||||||
|
}
|
||||||
|
output
|
||||||
|
}
|
||||||
|
|
||||||
/// Describe the five tools sent to either inference API.
|
/// Describe the five tools sent to either inference API.
|
||||||
pub fn definitions() -> Vec<Tool> {
|
pub fn definitions() -> Vec<Tool> {
|
||||||
vec![
|
vec![
|
||||||
Tool {
|
Tool {
|
||||||
name: "call",
|
name: "call",
|
||||||
description: "Call a plugin method through the core. Await its result. Set detach to let its process survive this caller.",
|
description: "Call a plugin method through the core. Await its result. Set detach to let its process survive this caller. Output is capped at 65536 bytes with a truncation marker.",
|
||||||
parameters: schema(
|
parameters: schema(
|
||||||
json!({
|
json!({
|
||||||
"method": {"type": "string", "description": "Qualified plugin method"},
|
"method": {"type": "string", "description": "Qualified plugin method"},
|
||||||
@@ -21,7 +38,7 @@ pub fn definitions() -> Vec<Tool> {
|
|||||||
},
|
},
|
||||||
Tool {
|
Tool {
|
||||||
name: "read_file",
|
name: "read_file",
|
||||||
description: "Read a text file in the world, including `/proc`. Reads all bytes by default; `offset` and `max_bytes` are optional.",
|
description: "Read a text file in the world, including `/proc`. `offset` and `max_bytes` are optional. Output is capped at 65536 bytes with a truncation marker.",
|
||||||
parameters: schema(
|
parameters: schema(
|
||||||
json!({
|
json!({
|
||||||
"path": {"type": "string"},
|
"path": {"type": "string"},
|
||||||
@@ -33,7 +50,7 @@ pub fn definitions() -> Vec<Tool> {
|
|||||||
},
|
},
|
||||||
Tool {
|
Tool {
|
||||||
name: "write_file",
|
name: "write_file",
|
||||||
description: "Write a text file in the world. Parent folders are created.",
|
description: "Write a text file in the world. Parent folders are created. Output is capped at 65536 bytes with a truncation marker.",
|
||||||
parameters: schema(
|
parameters: schema(
|
||||||
json!({"path": {"type": "string"}, "content": {"type": "string"}}),
|
json!({"path": {"type": "string"}, "content": {"type": "string"}}),
|
||||||
&["path", "content"],
|
&["path", "content"],
|
||||||
@@ -41,12 +58,12 @@ pub fn definitions() -> Vec<Tool> {
|
|||||||
},
|
},
|
||||||
Tool {
|
Tool {
|
||||||
name: "list_dir",
|
name: "list_dir",
|
||||||
description: "List entries in a world directory. Returns each name, type, and size.",
|
description: "List entries in a world directory. Returns each name, type, and size. Output is capped at 65536 bytes with a truncation marker.",
|
||||||
parameters: schema(json!({"path": {"type": "string"}}), &["path"]),
|
parameters: schema(json!({"path": {"type": "string"}}), &["path"]),
|
||||||
},
|
},
|
||||||
Tool {
|
Tool {
|
||||||
name: "copy_dir",
|
name: "copy_dir",
|
||||||
description: "Copy a directory tree, for example a plugin source to a draft folder. Skips `target/` folders.",
|
description: "Copy a directory tree, for example a plugin source to a draft folder. Skips `target/` folders. Output is capped at 65536 bytes with a truncation marker.",
|
||||||
parameters: schema(
|
parameters: schema(
|
||||||
json!({"from": {"type": "string"}, "to": {"type": "string"}}),
|
json!({"from": {"type": "string"}, "to": {"type": "string"}}),
|
||||||
&["from", "to"],
|
&["from", "to"],
|
||||||
@@ -128,6 +145,15 @@ fn list_dir(args: &Value) -> io::Result<Value> {
|
|||||||
|
|
||||||
/// Copy a tree and report its file count.
|
/// Copy a tree and report its file count.
|
||||||
fn copy_dir(args: &Value) -> io::Result<Value> {
|
fn copy_dir(args: &Value) -> io::Result<Value> {
|
||||||
|
let from = std::fs::canonicalize(args["from"].as_str().unwrap_or_default())?;
|
||||||
|
let to = args["to"].as_str().unwrap_or_default();
|
||||||
|
std::fs::create_dir_all(to)?;
|
||||||
|
if std::fs::canonicalize(to)?.starts_with(&from) {
|
||||||
|
return Err(io::Error::new(
|
||||||
|
io::ErrorKind::InvalidInput,
|
||||||
|
"destination is inside the source",
|
||||||
|
));
|
||||||
|
}
|
||||||
let count = copy_tree(
|
let count = copy_tree(
|
||||||
args["from"].as_str().unwrap_or_default(),
|
args["from"].as_str().unwrap_or_default(),
|
||||||
args["to"].as_str().unwrap_or_default(),
|
args["to"].as_str().unwrap_or_default(),
|
||||||
@@ -155,3 +181,26 @@ fn copy_tree(from: &str, to: &str) -> io::Result<u64> {
|
|||||||
}
|
}
|
||||||
Ok(count)
|
Ok(count)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
#[test]
|
||||||
|
fn tool_output_limit_preserves_utf8_and_marks_truncation() {
|
||||||
|
let limited = super::limit_output("😀".repeat(40_000));
|
||||||
|
assert!(limited.len() <= super::MAX_OUTPUT_BYTES);
|
||||||
|
assert!(limited.ends_with("[tool output truncated at 65536 bytes]"));
|
||||||
|
assert_eq!(super::limit_output("small".into()), "small");
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn copy_rejects_a_destination_inside_the_source() {
|
||||||
|
let root = if cfg!(target_os = "wasi") {
|
||||||
|
std::path::PathBuf::from("/copy-test")
|
||||||
|
} else {
|
||||||
|
std::env::temp_dir().join(format!("sausage-copy-{}", std::process::id()))
|
||||||
|
};
|
||||||
|
std::fs::create_dir_all(&root).unwrap();
|
||||||
|
let result = super::copy_dir(&serde_json::json!({"from":root,"to":root.join("target")}));
|
||||||
|
let _ = std::fs::remove_dir_all(root);
|
||||||
|
assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::InvalidInput);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+147
-3
@@ -299,20 +299,25 @@ async fn tools_and_interim_notifications() {
|
|||||||
),
|
),
|
||||||
("read_file", json!({"path":"/state/work/missing"})),
|
("read_file", json!({"path":"/state/work/missing"})),
|
||||||
("call", json!({"method":"absent.run","params":{}})),
|
("call", json!({"method":"absent.run","params":{}})),
|
||||||
|
("call", json!({"method":"probe.fail","params":{}})),
|
||||||
];
|
];
|
||||||
reply
|
reply
|
||||||
.send((200, server.answer("I will inspect the world.", &calls)))
|
.send((200, server.answer("I will inspect the world.", &calls)))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let (body, reply) = server.next().await;
|
let (body, reply) = server.next().await;
|
||||||
let results = outputs(&body, api);
|
let results = outputs(&body, api);
|
||||||
assert_eq!(results.len(), 7);
|
assert_eq!(results.len(), 8);
|
||||||
assert_eq!(results[0], 9);
|
assert_eq!(results[0], 9);
|
||||||
assert!(results[1]["content"].as_str().unwrap().contains("JSON-RPC"));
|
assert!(results[1]["content"].as_str().unwrap().contains("JSON-RPC"));
|
||||||
assert_eq!(results[2]["written"], "/state/work/source/note.txt");
|
assert_eq!(results[2]["written"], "/state/work/source/note.txt");
|
||||||
assert_eq!(results[3]["entries"][0]["name"], "note.txt");
|
assert_eq!(results[3]["entries"][0]["name"], "note.txt");
|
||||||
assert_eq!(results[4]["copied_files"], 1);
|
assert_eq!(results[4]["copied_files"], 1);
|
||||||
assert!(results[5]["error"].is_string());
|
assert!(results[5]["error"]["message"].is_string());
|
||||||
assert!(results[6]["error"].is_string());
|
assert!(results[6]["error"]["code"].is_number());
|
||||||
|
assert_eq!(
|
||||||
|
results[7]["error"],
|
||||||
|
json!({"code":-32010,"message":"deliberate","data":{"diagnostic":"probe error details"}})
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
std::fs::read_to_string(world.0.join("state/work/copied/note.txt")).unwrap(),
|
std::fs::read_to_string(world.0.join("state/work/copied/note.txt")).unwrap(),
|
||||||
"saved"
|
"saved"
|
||||||
@@ -407,3 +412,142 @@ async fn context_window_advances_by_complete_turns() {
|
|||||||
host.stop().await;
|
host.stop().await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Oversized tool outputs are bounded, and failed retries do not commit a new history cursor.
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn oversized_current_turn_preserves_the_previous_cursor() {
|
||||||
|
for api in APIS {
|
||||||
|
let mut server = Llm::new(api).await;
|
||||||
|
let world = TestWorld::new(&server);
|
||||||
|
std::fs::write(world.0.join("large.txt"), "x".repeat(200_000)).unwrap();
|
||||||
|
let host = world.start().await;
|
||||||
|
let first = chat(&host, "old retained turn");
|
||||||
|
let (_, reply) = server.next().await;
|
||||||
|
reply
|
||||||
|
.send((200, server.answer("old retained answer", &[])))
|
||||||
|
.unwrap();
|
||||||
|
first.await.unwrap();
|
||||||
|
let skip = world.0.join("state/agent/history.skip");
|
||||||
|
std::fs::write(&skip, "0").unwrap();
|
||||||
|
let caller = host.clone();
|
||||||
|
let turn = tokio::spawn(async move {
|
||||||
|
caller
|
||||||
|
.call("agent.chat", json!({"text":"read a large tool result"}))
|
||||||
|
.await
|
||||||
|
});
|
||||||
|
let (_, reply) = server.next().await;
|
||||||
|
reply
|
||||||
|
.send((
|
||||||
|
200,
|
||||||
|
server.answer("", &[("read_file", json!({"path":"/large.txt"}))]),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
let (body, reply) = server.next().await;
|
||||||
|
let serialized = input(&body, api).to_string();
|
||||||
|
assert!(serialized.contains("[tool output truncated"));
|
||||||
|
assert!(serialized.len() < 70_000);
|
||||||
|
reply
|
||||||
|
.send((413, json!({"type":"request_too_large"})))
|
||||||
|
.unwrap();
|
||||||
|
let (body, reply) = server.next().await;
|
||||||
|
assert!(!input(&body, api).to_string().contains("old retained turn"));
|
||||||
|
reply
|
||||||
|
.send((413, json!({"type":"request_too_large"})))
|
||||||
|
.unwrap();
|
||||||
|
assert!(turn.await.unwrap().is_err());
|
||||||
|
assert_eq!(std::fs::read_to_string(&skip).unwrap(), "0");
|
||||||
|
host.stop().await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Provider-private items are replayed only for their original API and model.
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn private_items_are_scoped_to_the_model() {
|
||||||
|
for api in APIS {
|
||||||
|
let mut server = Llm::new(api).await;
|
||||||
|
let world = TestWorld::new(&server);
|
||||||
|
let config_path = world.0.join("config/agent.json");
|
||||||
|
let mut config: Value =
|
||||||
|
serde_json::from_slice(&std::fs::read(&config_path).unwrap()).unwrap();
|
||||||
|
config["thinking"] = json!("high");
|
||||||
|
std::fs::write(config_path, config.to_string()).unwrap();
|
||||||
|
std::fs::create_dir_all(world.0.join("state/agent")).unwrap();
|
||||||
|
let mut entries = vec![json!({"kind":"user","text":"old turn"})];
|
||||||
|
for (model, marker) in [
|
||||||
|
("another-model", "old-private-marker"),
|
||||||
|
("scripted-test", "current-private-marker"),
|
||||||
|
] {
|
||||||
|
let item = if api == APIS[0] {
|
||||||
|
json!({"type":"reasoning","encrypted_content":marker})
|
||||||
|
} else {
|
||||||
|
json!({"type":"thinking","thinking":"private","signature":marker})
|
||||||
|
};
|
||||||
|
entries.push(json!({"kind":"opaque","api":api,"model":model,"item":item}));
|
||||||
|
}
|
||||||
|
entries.push(json!({"kind":"assistant","text":"old answer"}));
|
||||||
|
std::fs::write(
|
||||||
|
world.0.join("state/agent/history.jsonl"),
|
||||||
|
entries
|
||||||
|
.iter()
|
||||||
|
.map(|entry| format!("{entry}\n"))
|
||||||
|
.collect::<String>(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let host = world.start().await;
|
||||||
|
let turn = chat(&host, "next turn");
|
||||||
|
let (body, reply) = server.next().await;
|
||||||
|
let text = input(&body, api).to_string();
|
||||||
|
assert!(!text.contains("old-private-marker"));
|
||||||
|
assert!(text.contains("current-private-marker"));
|
||||||
|
reply.send((200, server.answer("done", &[]))).unwrap();
|
||||||
|
assert_eq!(turn.await.unwrap(), "done");
|
||||||
|
host.stop().await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Separate assistant messages retain a separator in the final answer.
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn final_texts_have_a_separator() {
|
||||||
|
for api in APIS {
|
||||||
|
let mut server = Llm::new(api).await;
|
||||||
|
let world = TestWorld::new(&server);
|
||||||
|
let host = world.start().await;
|
||||||
|
let turn = chat(&host, "two paragraphs");
|
||||||
|
let (_, reply) = server.next().await;
|
||||||
|
let mut answer = server.answer("first", &[]);
|
||||||
|
if api == APIS[0] {
|
||||||
|
answer["output"]
|
||||||
|
.as_array_mut()
|
||||||
|
.unwrap()
|
||||||
|
.push(json!({"type":"message","content":[{"type":"output_text","text":"second"}]}));
|
||||||
|
} else {
|
||||||
|
answer["content"]
|
||||||
|
.as_array_mut()
|
||||||
|
.unwrap()
|
||||||
|
.push(json!({"type":"text","text":"second"}));
|
||||||
|
}
|
||||||
|
reply.send((200, answer)).unwrap();
|
||||||
|
assert_eq!(turn.await.unwrap(), "first\n\nsecond");
|
||||||
|
host.stop().await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An incomplete response containing reasoning but no text is an error.
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn incomplete_without_text_is_an_error() {
|
||||||
|
let mut server = Llm::new(APIS[0]).await;
|
||||||
|
let world = TestWorld::new(&server);
|
||||||
|
let host = world.start().await;
|
||||||
|
let caller = host.clone();
|
||||||
|
let turn =
|
||||||
|
tokio::spawn(async move { caller.call("agent.chat", json!({"text":"answer"})).await });
|
||||||
|
let (_, reply) = server.next().await;
|
||||||
|
reply.send((200, json!({"status":"incomplete","output":[{"type":"reasoning","encrypted_content":"private"}]}))).unwrap();
|
||||||
|
assert!(
|
||||||
|
turn.await.unwrap().unwrap_err()["message"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.contains("incomplete response")
|
||||||
|
);
|
||||||
|
host.stop().await;
|
||||||
|
}
|
||||||
|
|||||||
+27
-11
@@ -1,5 +1,5 @@
|
|||||||
//! Generate Rust clients from OpenRPC documents with typify's Draft 7 converter.
|
//! Generate Rust clients from OpenRPC documents with typify's Draft 7 converter.
|
||||||
use quote::{format_ident, quote};
|
use quote::quote;
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
/// Generate a formatted private client module for a provider's document.
|
/// Generate a formatted private client module for a provider's document.
|
||||||
@@ -22,8 +22,9 @@ pub fn client_rs(name: &str, doc: &Value) -> Result<String, String> {
|
|||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let module =
|
let module = syn::parse_str::<syn::Ident>(&name.replace('-', "_"))
|
||||||
syn::parse_str::<syn::Ident>(&name.replace('-', "_")).map_err(|e| e.to_string())?;
|
.or_else(|_| syn::parse_str::<syn::Ident>(&format!("r#{}", name.replace('-', "_"))))
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
let mut space = typify::TypeSpace::default();
|
let mut space = typify::TypeSpace::default();
|
||||||
let mut defs = doc["components"]["schemas"]
|
let mut defs = doc["components"]["schemas"]
|
||||||
.as_object()
|
.as_object()
|
||||||
@@ -47,19 +48,32 @@ pub fn client_rs(name: &str, doc: &Value) -> Result<String, String> {
|
|||||||
let mut result = vec![];
|
let mut result = vec![];
|
||||||
for method in methods.as_array().into_iter().flatten() {
|
for method in methods.as_array().into_iter().flatten() {
|
||||||
let wire = method["name"].as_str().ok_or("method name missing")?;
|
let wire = method["name"].as_str().ok_or("method name missing")?;
|
||||||
let ident = syn::parse_str::<syn::Ident>(wire).map_err(|e| e.to_string())?;
|
let ident = syn::parse_str::<syn::Ident>(wire)
|
||||||
let mut ps=method.get("x-sausage-params").cloned().unwrap_or_else(|| {
|
.or_else(|_| syn::parse_str::<syn::Ident>(&format!("r#{wire}")))
|
||||||
let mut properties=serde_json::Map::new();let mut required=vec![];
|
.map_err(|e| e.to_string())?;
|
||||||
for p in method["params"].as_array().into_iter().flatten(){let n=p["name"].as_str().unwrap();properties.insert(n.into(),p["schema"].clone());if p["required"]==true{required.push(n);}}
|
|
||||||
json!({"title":format!("{}Params",wire.split('_').map(|s|{let mut c=s.chars();c.next().unwrap().to_uppercase().to_string()+c.as_str()}).collect::<String>()),"type":"object","properties":properties,"required":required})
|
|
||||||
});
|
|
||||||
local_refs(&mut ps);
|
|
||||||
let mut rs = method["result"]["schema"].clone();
|
let mut rs = method["result"]["schema"].clone();
|
||||||
local_refs(&mut rs);
|
local_refs(&mut rs);
|
||||||
let empty = method["params"].as_array().is_none_or(Vec::is_empty);
|
let empty = method["params"].as_array().is_none_or(Vec::is_empty);
|
||||||
let p = if empty {
|
let p = if empty {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
|
let mut properties = serde_json::Map::new();
|
||||||
|
let mut required = vec![];
|
||||||
|
for param in method["params"].as_array().into_iter().flatten() {
|
||||||
|
let name = param["name"].as_str().ok_or("parameter name missing")?;
|
||||||
|
properties.insert(name.into(), param["schema"].clone());
|
||||||
|
if param["required"] == true {
|
||||||
|
required.push(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut ps = method.get("x-sausage-params").cloned().unwrap_or_else(|| {
|
||||||
|
let title = wire.split('_').filter(|s| !s.is_empty()).map(|s| {
|
||||||
|
let mut chars = s.chars();
|
||||||
|
chars.next().unwrap().to_uppercase().to_string() + chars.as_str()
|
||||||
|
}).collect::<String>();
|
||||||
|
json!({"title":format!("{title}Params"),"type":"object","properties":properties,"required":required})
|
||||||
|
});
|
||||||
|
local_refs(&mut ps);
|
||||||
Some(
|
Some(
|
||||||
space
|
space
|
||||||
.add_type(&serde_json::from_value(ps).map_err(|e| e.to_string())?)
|
.add_type(&serde_json::from_value(ps).map_err(|e| e.to_string())?)
|
||||||
@@ -102,7 +116,9 @@ pub fn client_rs(name: &str, doc: &Value) -> Result<String, String> {
|
|||||||
let functions = methods(&mut space, name, &doc["methods"], false)?;
|
let functions = methods(&mut space, name, &doc["methods"], false)?;
|
||||||
let mut endpoints = vec![];
|
let mut endpoints = vec![];
|
||||||
for (name, description) in doc["x-sausage-endpoints"].as_object().into_iter().flatten() {
|
for (name, description) in doc["x-sausage-endpoints"].as_object().into_iter().flatten() {
|
||||||
let ident = format_ident!("{}", name);
|
let ident = syn::parse_str::<syn::Ident>(name)
|
||||||
|
.or_else(|_| syn::parse_str::<syn::Ident>(&format!("r#{name}")))
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
let functions = methods(&mut space, "", &description["methods"], true)?;
|
let functions = methods(&mut space, "", &description["methods"], true)?;
|
||||||
endpoints.push(quote!{pub mod #ident {use super::*;pub struct Client(::sausage_plugin::Endpoint);pub async fn connect(path:impl AsRef<::std::path::Path>)->::sausage_plugin::Result<Client>{let endpoint=::sausage_plugin::connect(path).await?;endpoint.require_interface(#name)?;Ok(Client(endpoint))} impl Client {#(#functions)*}}});
|
endpoints.push(quote!{pub mod #ident {use super::*;pub struct Client(::sausage_plugin::Endpoint);pub async fn connect(path:impl AsRef<::std::path::Path>)->::sausage_plugin::Result<Client>{let endpoint=::sausage_plugin::connect(path).await?;endpoint.require_interface(#name)?;Ok(Client(endpoint))} impl Client {#(#functions)*}}});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
//! A provider may add its first optional named parameter.
|
||||||
|
use serde_json::json;
|
||||||
|
#[test]
|
||||||
|
fn first_optional_param_is_compatible() {
|
||||||
|
let consumer =
|
||||||
|
json!({"methods":[{"name":"status","params":[],"result":{"schema":{"type":"string"}}}]});
|
||||||
|
let mut provider = consumer.clone();
|
||||||
|
provider["methods"][0]["params"] =
|
||||||
|
json!([{"name":"detail","required":false,"schema":{"type":"boolean"}}]);
|
||||||
|
provider["methods"][0]["x-sausage-params"] =
|
||||||
|
json!({"type":"object","properties":{"detail":{"type":"boolean"}}});
|
||||||
|
assert!(sausage_interface::compat::check(&consumer, &provider).is_empty());
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
//! Client generation accepts unusual method names and reports invalid parameter names.
|
||||||
|
use serde_json::json;
|
||||||
|
#[test]
|
||||||
|
fn underscore_segments_and_empty_params_do_not_panic() {
|
||||||
|
for name in ["_internal", "a__b", "__"] {
|
||||||
|
for params in [
|
||||||
|
json!([]),
|
||||||
|
json!([{"name":"value","schema":{"type":"string"}}]),
|
||||||
|
] {
|
||||||
|
let doc = json!({"methods":[{"name":name,"params":params,"result":{"schema":{"type":"null"}}}]});
|
||||||
|
syn::parse_file(&sausage_interface::client_rs("probe", &doc).unwrap()).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn unnamed_param_returns_error() {
|
||||||
|
let doc = json!({"methods":[{"name":"call","params":[{"schema":{"type":"string"}}],"result":{"schema":{"type":"null"}}}]});
|
||||||
|
assert!(sausage_interface::client_rs("probe", &doc).is_err());
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn invalid_endpoint_identifier_returns_error() {
|
||||||
|
assert!(
|
||||||
|
sausage_interface::client_rs(
|
||||||
|
"probe",
|
||||||
|
&json!({"x-sausage-endpoints":{"bad-name":{"methods":[]}}})
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
+32
-8
@@ -2,8 +2,8 @@
|
|||||||
use proc_macro::TokenStream;
|
use proc_macro::TokenStream;
|
||||||
use quote::quote;
|
use quote::quote;
|
||||||
use syn::{
|
use syn::{
|
||||||
Attribute, Fields, FnArg, ImplItem, Item, ItemImpl, ReturnType, Type, parse_macro_input,
|
Attribute, Fields, FnArg, ImplItem, Item, ItemImpl, ReturnType, Type, ext::IdentExt,
|
||||||
spanned::Spanned,
|
parse_macro_input, spanned::Spanned,
|
||||||
};
|
};
|
||||||
fn docs(attrs: &[Attribute]) -> String {
|
fn docs(attrs: &[Attribute]) -> String {
|
||||||
attrs
|
attrs
|
||||||
@@ -30,6 +30,12 @@ fn documented(attrs: &[Attribute], span: proc_macro2::Span) -> syn::Result<()> {
|
|||||||
}
|
}
|
||||||
fn serde_subset(attrs: &[Attribute]) -> syn::Result<()> {
|
fn serde_subset(attrs: &[Attribute]) -> syn::Result<()> {
|
||||||
for a in attrs {
|
for a in attrs {
|
||||||
|
if a.path().is_ident("schemars") {
|
||||||
|
return Err(syn::Error::new(
|
||||||
|
a.span(),
|
||||||
|
"schemars attributes are outside the schema subset",
|
||||||
|
));
|
||||||
|
}
|
||||||
if a.path().is_ident("serde") {
|
if a.path().is_ident("serde") {
|
||||||
a.parse_nested_meta(|m| {
|
a.parse_nested_meta(|m| {
|
||||||
if !["rename", "rename_all", "default", "skip_serializing_if"]
|
if !["rename", "rename_all", "default", "skip_serializing_if"]
|
||||||
@@ -145,6 +151,8 @@ pub fn rpc(args: TokenStream, input: TokenStream) -> TokenStream {
|
|||||||
let mut arms = vec![];
|
let mut arms = vec![];
|
||||||
let mut entries = vec![];
|
let mut entries = vec![];
|
||||||
let mut stop = quote!();
|
let mut stop = quote!();
|
||||||
|
let mut has_stop = false;
|
||||||
|
let mut has_retired = false;
|
||||||
for member in &mut item.items {
|
for member in &mut item.items {
|
||||||
let ImplItem::Fn(f) = member else { continue };
|
let ImplItem::Fn(f) = member else { continue };
|
||||||
let retired = f.attrs.iter().any(|a| {
|
let retired = f.attrs.iter().any(|a| {
|
||||||
@@ -159,6 +167,18 @@ pub fn rpc(args: TokenStream, input: TokenStream) -> TokenStream {
|
|||||||
.last()
|
.last()
|
||||||
.is_some_and(|s| s.ident == "on_stop")
|
.is_some_and(|s| s.ident == "on_stop")
|
||||||
});
|
});
|
||||||
|
if stopping && std::mem::replace(&mut has_stop, true) {
|
||||||
|
return Err(syn::Error::new(
|
||||||
|
f.span(),
|
||||||
|
"only one on_stop method is allowed",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if retired && std::mem::replace(&mut has_retired, true) {
|
||||||
|
return Err(syn::Error::new(
|
||||||
|
f.span(),
|
||||||
|
"only one on_retired method is allowed",
|
||||||
|
));
|
||||||
|
}
|
||||||
f.attrs.retain(|a| {
|
f.attrs.retain(|a| {
|
||||||
!a.path()
|
!a.path()
|
||||||
.segments
|
.segments
|
||||||
@@ -207,7 +227,7 @@ pub fn rpc(args: TokenStream, input: TokenStream) -> TokenStream {
|
|||||||
let wire = if retired {
|
let wire = if retired {
|
||||||
"rpc.retired".to_string()
|
"rpc.retired".to_string()
|
||||||
} else {
|
} else {
|
||||||
name.to_string()
|
name.unraw().to_string()
|
||||||
};
|
};
|
||||||
let p = param
|
let p = param
|
||||||
.map(|t| quote!(#t))
|
.map(|t| quote!(#t))
|
||||||
@@ -237,14 +257,18 @@ pub fn rpc(args: TokenStream, input: TokenStream) -> TokenStream {
|
|||||||
entries.push(quote!(::sausage_plugin::method::<#p,#output>(&mut doc,#wire,#doc);));
|
entries.push(quote!(::sausage_plugin::method::<#p,#output>(&mut doc,#wire,#doc);));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let grace =
|
let grace = grace
|
||||||
grace.map(|n| quote!(doc["x-sausage-grace"]=::sausage_plugin::serde_json::json!(#n);));
|
.map(|n| quote!(doc.value["x-sausage-grace"]=::sausage_plugin::serde_json::json!(#n);));
|
||||||
let names: Vec<_> = endpoints.iter().map(|(n, _)| n.to_string()).collect();
|
let names: Vec<_> = endpoints
|
||||||
|
.iter()
|
||||||
|
.map(|(n, _)| n.unraw().to_string())
|
||||||
|
.collect();
|
||||||
let types: Vec<_> = endpoints.iter().map(|(_, t)| t).collect();
|
let types: Vec<_> = endpoints.iter().map(|(_, t)| t).collect();
|
||||||
Ok(quote! {#item impl ::sausage_plugin::Interface for #ty {
|
Ok(quote! {#item impl ::sausage_plugin::Interface for #ty {
|
||||||
fn endpoint_types(&self)->Vec<(::std::any::TypeId,String)>{vec![#((::std::any::TypeId::of::<#types>(),#names.into())),*]}
|
fn endpoint_types(&self)->Vec<(::std::any::TypeId,String)>{vec![#((::std::any::TypeId::of::<#types>(),#names.into())),*]}
|
||||||
fn methods_document()->::sausage_plugin::serde_json::Value {let mut doc=::sausage_plugin::document(env!("CARGO_PKG_NAME"),env!("CARGO_PKG_VERSION")); #(#entries)* #grace ::sausage_plugin::finish_document(doc)}
|
fn add_methods(mut doc:&mut ::sausage_plugin::Discovery) {#(#entries)*}
|
||||||
fn describe()->::sausage_plugin::serde_json::Value {let mut doc=<Self as ::sausage_plugin::Interface>::methods_document(); #(let endpoint=<#types as ::sausage_plugin::Interface>::methods_document();doc["x-sausage-endpoints"][#names]=::sausage_plugin::serde_json::json!({"methods":endpoint["methods"]});doc["components"]["schemas"].as_object_mut().unwrap().extend(endpoint["components"]["schemas"].as_object().unwrap().clone());)* doc}
|
fn methods_document()->::sausage_plugin::serde_json::Value {let mut doc=::sausage_plugin::document(env!("CARGO_PKG_NAME"),env!("CARGO_PKG_VERSION")); Self::add_methods(&mut doc); #grace ::sausage_plugin::finish_document(doc)}
|
||||||
|
fn describe()->::sausage_plugin::serde_json::Value {let mut doc=::sausage_plugin::document(env!("CARGO_PKG_NAME"),env!("CARGO_PKG_VERSION")); Self::add_methods(&mut doc); #grace #(doc.endpoint::<#types>(#names);)* ::sausage_plugin::finish_document(doc)}
|
||||||
fn discover(&self)->::sausage_plugin::serde_json::Value {<Self as ::sausage_plugin::Interface>::describe()}
|
fn discover(&self)->::sausage_plugin::serde_json::Value {<Self as ::sausage_plugin::Interface>::describe()}
|
||||||
fn dispatch<'a>(&'a self, method:&'a str, params: ::sausage_plugin::serde_json::Value,ctx:&'a ::sausage_plugin::Ctx)->::std::pin::Pin<Box<dyn ::std::future::Future<Output=::sausage_plugin::Result<::sausage_plugin::serde_json::Value>>+'a>> {Box::pin(async move {if !params.is_object() {return Err(::sausage_plugin::Error::new(-32602,"params must be an object"));} match method {#(#arms,)* _=>Err(::sausage_plugin::Error::new(-32601,"method not found"))}})}
|
fn dispatch<'a>(&'a self, method:&'a str, params: ::sausage_plugin::serde_json::Value,ctx:&'a ::sausage_plugin::Ctx)->::std::pin::Pin<Box<dyn ::std::future::Future<Output=::sausage_plugin::Result<::sausage_plugin::serde_json::Value>>+'a>> {Box::pin(async move {if !params.is_object() {return Err(::sausage_plugin::Error::new(-32602,"params must be an object"));} match method {#(#arms,)* _=>Err(::sausage_plugin::Error::new(-32601,"method not found"))}})}
|
||||||
#stop
|
#stop
|
||||||
|
|||||||
+3
-2
@@ -1,6 +1,7 @@
|
|||||||
//! Outgoing core calls and notification callbacks.
|
//! Outgoing core calls and notification callbacks.
|
||||||
use super::*;
|
use super::*;
|
||||||
/// An outgoing request builder.
|
/// An outgoing request builder.
|
||||||
|
#[must_use = "await the call to send it"]
|
||||||
pub struct Call {
|
pub struct Call {
|
||||||
method: String,
|
method: String,
|
||||||
params: Value,
|
params: Value,
|
||||||
@@ -43,7 +44,7 @@ impl IntoFuture for Call {
|
|||||||
let mut s = s.borrow_mut();
|
let mut s = s.borrow_mut();
|
||||||
if !s.active || s.stopping {
|
if !s.active || s.stopping {
|
||||||
return Err(Error::new(
|
return Err(Error::new(
|
||||||
-32002,
|
-32000,
|
||||||
"no core transport (or stdin reached EOF)",
|
"no core transport (or stdin reached EOF)",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -72,7 +73,7 @@ impl IntoFuture for Call {
|
|||||||
return Err(e);
|
return Err(e);
|
||||||
}
|
}
|
||||||
rx.await
|
rx.await
|
||||||
.unwrap_or_else(|_| Err(Error::new(-32002, "core transport closed")))
|
.unwrap_or_else(|_| Err(Error::new(-32000, "core transport closed")))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+45
-30
@@ -31,16 +31,7 @@ impl<T: SchemaType> SchemaType for Option<T> {}
|
|||||||
|
|
||||||
/// Create an OpenRPC document builder for generated interfaces.
|
/// Create an OpenRPC document builder for generated interfaces.
|
||||||
#[doc(hidden)]
|
#[doc(hidden)]
|
||||||
pub fn document(title: &str, version: &str) -> Value {
|
pub fn document(title: &str, version: &str) -> Discovery {
|
||||||
let mut doc = sausage_interface::Document::new(title, version).into_value();
|
|
||||||
doc["x-sdk-input"] = json!({});
|
|
||||||
doc["x-sdk-output"] = json!({});
|
|
||||||
doc
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Add a method with independent deserialize and serialize contracts.
|
|
||||||
#[doc(hidden)]
|
|
||||||
pub fn method<P: Params, R: SchemaType>(doc: &mut Value, name: &str, docs: &str) {
|
|
||||||
fn generator(serialize: bool) -> schemars::SchemaGenerator {
|
fn generator(serialize: bool) -> schemars::SchemaGenerator {
|
||||||
let mut settings = schemars::generate::SchemaSettings::draft07();
|
let mut settings = schemars::generate::SchemaSettings::draft07();
|
||||||
settings.definitions_path = if serialize {
|
settings.definitions_path = if serialize {
|
||||||
@@ -56,10 +47,44 @@ pub fn method<P: Params, R: SchemaType>(doc: &mut Value, name: &str, docs: &str)
|
|||||||
}
|
}
|
||||||
.into_generator()
|
.into_generator()
|
||||||
}
|
}
|
||||||
let mut input = generator(false);
|
Discovery {
|
||||||
let params_ref = serde_json::to_value(input.subschema_for::<P>()).unwrap();
|
value: sausage_interface::Document::new(title, version).into_value(),
|
||||||
let definitions = input.take_definitions(true);
|
input: generator(false),
|
||||||
let schema = &definitions[P::schema_name().as_ref()];
|
output: generator(true),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared generators for all methods and endpoint types in one document.
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub struct Discovery {
|
||||||
|
/// The OpenRPC document before reference resolution.
|
||||||
|
pub value: Value,
|
||||||
|
input: schemars::SchemaGenerator,
|
||||||
|
output: schemars::SchemaGenerator,
|
||||||
|
}
|
||||||
|
impl Discovery {
|
||||||
|
/// Add an endpoint with the same input and output generators.
|
||||||
|
pub fn endpoint<T: Interface>(&mut self, name: &str) {
|
||||||
|
let methods = std::mem::replace(&mut self.value["methods"], json!([]));
|
||||||
|
T::add_methods(self);
|
||||||
|
let endpoint = std::mem::replace(&mut self.value["methods"], methods);
|
||||||
|
self.value["x-sausage-endpoints"][name] = json!({"methods":endpoint});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a method with independent deserialize and serialize contracts.
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub fn method<P: Params, R: SchemaType>(doc: &mut Discovery, name: &str, docs: &str) {
|
||||||
|
let params_ref = if P::schema_id() == <Empty as schemars::JsonSchema>::schema_id() {
|
||||||
|
Value::Null
|
||||||
|
} else {
|
||||||
|
serde_json::to_value(doc.input.subschema_for::<P>()).unwrap()
|
||||||
|
};
|
||||||
|
let schema = params_ref["$ref"]
|
||||||
|
.as_str()
|
||||||
|
.and_then(|reference| reference.strip_prefix("#/sdk-input/"))
|
||||||
|
.and_then(|name| doc.input.definitions().get(name))
|
||||||
|
.unwrap_or(&Value::Null);
|
||||||
let params:Vec<_>=schema["properties"].as_object().into_iter().flatten().map(|(name,field)| {
|
let params:Vec<_>=schema["properties"].as_object().into_iter().flatten().map(|(name,field)| {
|
||||||
json!({
|
json!({
|
||||||
"name": name,
|
"name": name,
|
||||||
@@ -68,18 +93,7 @@ pub fn method<P: Params, R: SchemaType>(doc: &mut Value, name: &str, docs: &str)
|
|||||||
"schema": field,
|
"schema": field,
|
||||||
})
|
})
|
||||||
}).collect();
|
}).collect();
|
||||||
if !params.is_empty() {
|
let result_schema = serde_json::to_value(doc.output.subschema_for::<R>()).unwrap();
|
||||||
doc["x-sdk-input"]
|
|
||||||
.as_object_mut()
|
|
||||||
.unwrap()
|
|
||||||
.extend(definitions);
|
|
||||||
}
|
|
||||||
let mut output = generator(true);
|
|
||||||
let result_schema = serde_json::to_value(output.subschema_for::<R>()).unwrap();
|
|
||||||
doc["x-sdk-output"]
|
|
||||||
.as_object_mut()
|
|
||||||
.unwrap()
|
|
||||||
.extend(output.take_definitions(true));
|
|
||||||
let has_params = !params.is_empty();
|
let has_params = !params.is_empty();
|
||||||
let mut method = json!({
|
let mut method = json!({
|
||||||
"name":name,
|
"name":name,
|
||||||
@@ -92,14 +106,15 @@ pub fn method<P: Params, R: SchemaType>(doc: &mut Value, name: &str, docs: &str)
|
|||||||
if has_params {
|
if has_params {
|
||||||
method["x-sausage-params"] = params_ref;
|
method["x-sausage-params"] = params_ref;
|
||||||
}
|
}
|
||||||
doc["methods"].as_array_mut().unwrap().push(method);
|
doc.value["methods"].as_array_mut().unwrap().push(method);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve shared schema names after all methods contribute their contracts.
|
/// Resolve shared schema names after all methods contribute their contracts.
|
||||||
#[doc(hidden)]
|
#[doc(hidden)]
|
||||||
pub fn finish_document(mut doc: Value) -> Value {
|
pub fn finish_document(mut discovery: Discovery) -> Value {
|
||||||
let input = doc.as_object_mut().unwrap().remove("x-sdk-input").unwrap();
|
let input = serde_json::to_value(discovery.input.take_definitions(true)).unwrap();
|
||||||
let output = doc.as_object_mut().unwrap().remove("x-sdk-output").unwrap();
|
let output = serde_json::to_value(discovery.output.take_definitions(true)).unwrap();
|
||||||
|
let mut doc = discovery.value;
|
||||||
fn refs(value: &mut Value, names: &std::collections::HashMap<String, String>) {
|
fn refs(value: &mut Value, names: &std::collections::HashMap<String, String>) {
|
||||||
match value {
|
match value {
|
||||||
Value::Object(map) => {
|
Value::Object(map) => {
|
||||||
|
|||||||
+15
-17
@@ -7,6 +7,9 @@ use serde_json::json;
|
|||||||
use std::{future::IntoFuture, marker::PhantomData, path::Path, rc::Rc};
|
use std::{future::IntoFuture, marker::PhantomData, path::Path, rc::Rc};
|
||||||
|
|
||||||
/// A verified endpoint address and identity.
|
/// A verified endpoint address and identity.
|
||||||
|
///
|
||||||
|
/// Connect verifies and closes one connection. Each call opens a new connection
|
||||||
|
/// and repeats the identity handshake before its request. The first call costs three round trips.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
#[cfg_attr(not(target_os = "wasi"), allow(dead_code))]
|
#[cfg_attr(not(target_os = "wasi"), allow(dead_code))]
|
||||||
pub struct Endpoint {
|
pub struct Endpoint {
|
||||||
@@ -20,7 +23,7 @@ impl Endpoint {
|
|||||||
if self.interface == name {
|
if self.interface == name {
|
||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
Err(Error::new(-32002, "endpoint interface mismatch"))
|
Err(Error::new(-32000, "endpoint interface mismatch"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/// Build a typed endpoint request.
|
/// Build a typed endpoint request.
|
||||||
@@ -35,6 +38,7 @@ impl Endpoint {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
/// A lazy endpoint request with optional progress callbacks.
|
/// A lazy endpoint request with optional progress callbacks.
|
||||||
|
#[must_use = "await the endpoint call to send it"]
|
||||||
pub struct EndpointCall<T> {
|
pub struct EndpointCall<T> {
|
||||||
endpoint: Endpoint,
|
endpoint: Endpoint,
|
||||||
method: String,
|
method: String,
|
||||||
@@ -87,7 +91,7 @@ pub async fn listen<T: Interface>(path: impl AsRef<Path>, value: Rc<T>) -> Resul
|
|||||||
#[cfg(not(target_os = "wasi"))]
|
#[cfg(not(target_os = "wasi"))]
|
||||||
pub async fn listen<T: Interface>(_path: impl AsRef<Path>, _value: Rc<T>) -> Result<()> {
|
pub async fn listen<T: Interface>(_path: impl AsRef<Path>, _value: Rc<T>) -> Result<()> {
|
||||||
crate::transport::endpoint_name::<T>()?;
|
crate::transport::endpoint_name::<T>()?;
|
||||||
Err(Error::new(-32002, "endpoints require WASI"))
|
Err(Error::new(-32000, "endpoints require WASI"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read an endpoint file and verify its owner before returning a client.
|
/// Read an endpoint file and verify its owner before returning a client.
|
||||||
@@ -122,7 +126,7 @@ pub async fn connect(path: impl AsRef<Path>) -> Result<Endpoint> {
|
|||||||
/// Connect to an endpoint. Native programs require the WASI runtime.
|
/// Connect to an endpoint. Native programs require the WASI runtime.
|
||||||
#[cfg(not(target_os = "wasi"))]
|
#[cfg(not(target_os = "wasi"))]
|
||||||
pub async fn connect(_path: impl AsRef<Path>) -> Result<Endpoint> {
|
pub async fn connect(_path: impl AsRef<Path>) -> Result<Endpoint> {
|
||||||
Err(Error::new(-32002, "endpoints require WASI"))
|
Err(Error::new(-32000, "endpoints require WASI"))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "wasi")]
|
#[cfg(target_os = "wasi")]
|
||||||
@@ -139,11 +143,10 @@ async fn handshake(endpoint: &Endpoint) -> Result<wstd::net::TcpStream> {
|
|||||||
json!({"jsonrpc":"2.0","id":0,"method":"rpc.endpoint","params":{}}),
|
json!({"jsonrpc":"2.0","id":0,"method":"rpc.endpoint","params":{}}),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
let bytes = crate::lines::Lines::default()
|
let reply = crate::lines::Lines::default()
|
||||||
.next(&mut stream)
|
.rpc(&mut stream)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| Error::new(-32002, "endpoint closed"))?;
|
.ok_or_else(|| Error::new(-32000, "endpoint closed"))?;
|
||||||
let reply: Value = serde_json::from_slice(&bytes)?;
|
|
||||||
let result = &reply["result"];
|
let result = &reply["result"];
|
||||||
let plugin = endpoint
|
let plugin = endpoint
|
||||||
.owner
|
.owner
|
||||||
@@ -154,7 +157,7 @@ async fn handshake(endpoint: &Endpoint) -> Result<wstd::net::TcpStream> {
|
|||||||
|| result["interface"] != endpoint.interface
|
|| result["interface"] != endpoint.interface
|
||||||
|| result["plugin"].as_str() != plugin
|
|| result["plugin"].as_str() != plugin
|
||||||
{
|
{
|
||||||
return Err(Error::new(-32002, "endpoint identity mismatch"));
|
return Err(Error::new(-32000, "endpoint identity mismatch"));
|
||||||
}
|
}
|
||||||
Ok(stream)
|
Ok(stream)
|
||||||
}
|
}
|
||||||
@@ -172,8 +175,7 @@ async fn endpoint_request(
|
|||||||
json!({"jsonrpc":"2.0","id":1,"method":method,"params":params}),
|
json!({"jsonrpc":"2.0","id":1,"method":method,"params":params}),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
while let Some(bytes) = lines.next(&mut stream).await? {
|
while let Some(value) = lines.rpc(&mut stream).await? {
|
||||||
let value: Value = serde_json::from_slice(&bytes)?;
|
|
||||||
if value["method"] == "core.notify" {
|
if value["method"] == "core.notify" {
|
||||||
if let Some(f) = &mut notify {
|
if let Some(f) = &mut notify {
|
||||||
f(
|
f(
|
||||||
@@ -188,7 +190,7 @@ async fn endpoint_request(
|
|||||||
return Ok(value["result"].clone());
|
return Ok(value["result"].clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(Error::new(-32002, "endpoint closed before response"))
|
Err(Error::new(-32000, "endpoint closed before response"))
|
||||||
}
|
}
|
||||||
#[cfg(not(target_os = "wasi"))]
|
#[cfg(not(target_os = "wasi"))]
|
||||||
async fn endpoint_request(
|
async fn endpoint_request(
|
||||||
@@ -197,7 +199,7 @@ async fn endpoint_request(
|
|||||||
_params: Value,
|
_params: Value,
|
||||||
_notify: Option<Notify>,
|
_notify: Option<Notify>,
|
||||||
) -> Result<Value> {
|
) -> Result<Value> {
|
||||||
Err(Error::new(-32002, "endpoints require WASI"))
|
Err(Error::new(-32000, "endpoints require WASI"))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "wasi")]
|
#[cfg(target_os = "wasi")]
|
||||||
@@ -209,11 +211,7 @@ async fn serve_connection<T: Interface>(
|
|||||||
use futures::{FutureExt, StreamExt};
|
use futures::{FutureExt, StreamExt};
|
||||||
let (mut reader, mut writer) = stream.split();
|
let (mut reader, mut writer) = stream.split();
|
||||||
let mut lines = crate::lines::Lines::default();
|
let mut lines = crate::lines::Lines::default();
|
||||||
while let Some(bytes) = lines.next(&mut reader).await? {
|
while let Some(request) = lines.rpc(&mut reader).await? {
|
||||||
let request: Value = serde_json::from_slice(&bytes)?;
|
|
||||||
if request["jsonrpc"] != "2.0" {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let method = request["method"].as_str().unwrap_or_default();
|
let method = request["method"].as_str().unwrap_or_default();
|
||||||
let (tx, mut rx) = futures::channel::mpsc::unbounded();
|
let (tx, mut rx) = futures::channel::mpsc::unbounded();
|
||||||
let ctx = Ctx {
|
let ctx = Ctx {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ pub struct Import {
|
|||||||
inventory::collect!(Import);
|
inventory::collect!(Import);
|
||||||
|
|
||||||
/// An outgoing call whose result decodes to a generated Rust type.
|
/// An outgoing call whose result decodes to a generated Rust type.
|
||||||
|
#[must_use = "await the call to send it"]
|
||||||
pub struct TypedCall<T> {
|
pub struct TypedCall<T> {
|
||||||
call: Call,
|
call: Call,
|
||||||
result: PhantomData<T>,
|
result: PhantomData<T>,
|
||||||
|
|||||||
@@ -44,6 +44,8 @@
|
|||||||
//! [`listen`] publishes a declared interface at an endpoint file.
|
//! [`listen`] publishes a declared interface at an endpoint file.
|
||||||
//! [`connect`] verifies its owner before it returns an [`Endpoint`].
|
//! [`connect`] verifies its owner before it returns an [`Endpoint`].
|
||||||
//! Endpoint calls report progress through [`EndpointCall::on_notify`].
|
//! Endpoint calls report progress through [`EndpointCall::on_notify`].
|
||||||
|
//! Local transport errors use code -32000: no transport, stdin EOF, a closed endpoint,
|
||||||
|
//! or an endpoint identity or interface mismatch. Code -32002 belongs to core process failures.
|
||||||
//! Log with `eprintln!`. Stdout belongs to JSON-RPC.
|
//! Log with `eprintln!`. Stdout belongs to JSON-RPC.
|
||||||
#![warn(missing_docs)]
|
#![warn(missing_docs)]
|
||||||
mod discovery;
|
mod discovery;
|
||||||
|
|||||||
+45
-4
@@ -1,22 +1,34 @@
|
|||||||
//! Buffered newline framing shared by stdin and endpoint connections.
|
//! Buffered newline framing shared by stdin and endpoint connections.
|
||||||
#[cfg(target_os = "wasi")]
|
#[cfg(any(target_os = "wasi", test))]
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub(crate) struct Lines {
|
pub(crate) struct Lines {
|
||||||
bytes: Vec<u8>,
|
bytes: Vec<u8>,
|
||||||
|
scanned: usize,
|
||||||
}
|
}
|
||||||
#[cfg(target_os = "wasi")]
|
#[cfg(any(target_os = "wasi", test))]
|
||||||
impl Lines {
|
impl Lines {
|
||||||
|
fn frame(&mut self) -> Option<Vec<u8>> {
|
||||||
|
if let Some(end) = self.bytes[self.scanned..].iter().position(|b| *b == b'\n') {
|
||||||
|
let end = self.scanned + end;
|
||||||
|
self.scanned = 0;
|
||||||
|
return Some(self.bytes.drain(..=end).collect());
|
||||||
|
}
|
||||||
|
self.scanned = self.bytes.len();
|
||||||
|
None
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "wasi")]
|
||||||
pub(crate) async fn next(
|
pub(crate) async fn next(
|
||||||
&mut self,
|
&mut self,
|
||||||
input: &mut impl wstd::io::AsyncRead,
|
input: &mut impl wstd::io::AsyncRead,
|
||||||
) -> std::io::Result<Option<Vec<u8>>> {
|
) -> std::io::Result<Option<Vec<u8>>> {
|
||||||
loop {
|
loop {
|
||||||
if let Some(end) = self.bytes.iter().position(|b| *b == b'\n') {
|
if let Some(line) = self.frame() {
|
||||||
return Ok(Some(self.bytes.drain(..=end).collect()));
|
return Ok(Some(line));
|
||||||
}
|
}
|
||||||
let mut buffer = [0; 8192];
|
let mut buffer = [0; 8192];
|
||||||
let count = input.read(&mut buffer).await?;
|
let count = input.read(&mut buffer).await?;
|
||||||
if count == 0 {
|
if count == 0 {
|
||||||
|
self.scanned = 0;
|
||||||
return Ok(if self.bytes.is_empty() {
|
return Ok(if self.bytes.is_empty() {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
@@ -26,4 +38,33 @@ impl Lines {
|
|||||||
self.bytes.extend_from_slice(&buffer[..count]);
|
self.bytes.extend_from_slice(&buffer[..count]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#[cfg(target_os = "wasi")]
|
||||||
|
pub(crate) async fn rpc(
|
||||||
|
&mut self,
|
||||||
|
input: &mut impl wstd::io::AsyncRead,
|
||||||
|
) -> std::io::Result<Option<serde_json::Value>> {
|
||||||
|
while let Some(bytes) = self.next(input).await? {
|
||||||
|
if let Ok(value) = serde_json::from_slice::<serde_json::Value>(&bytes)
|
||||||
|
&& value.is_object()
|
||||||
|
&& value["jsonrpc"] == "2.0"
|
||||||
|
{
|
||||||
|
return Ok(Some(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
#[test]
|
||||||
|
fn incomplete_frames_retain_the_scanned_length() {
|
||||||
|
let mut lines = super::Lines::default();
|
||||||
|
lines.bytes.extend_from_slice(b"first");
|
||||||
|
assert!(lines.frame().is_none());
|
||||||
|
assert_eq!(lines.scanned, 5);
|
||||||
|
lines.bytes.extend_from_slice(b"\nsecond\n");
|
||||||
|
assert_eq!(lines.frame().unwrap(), b"first\n");
|
||||||
|
assert_eq!(lines.frame().unwrap(), b"second\n");
|
||||||
|
assert!(lines.frame().is_none());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
//! Advisory lock acquisition and guard lifetime.
|
//! Advisory lock acquisition and guard lifetime.
|
||||||
use super::*;
|
use super::*;
|
||||||
/// An advisory lock acquisition.
|
/// An advisory lock acquisition.
|
||||||
|
#[must_use = "await the lock to acquire it"]
|
||||||
pub struct Lock {
|
pub struct Lock {
|
||||||
path: String,
|
path: String,
|
||||||
wait: bool,
|
wait: bool,
|
||||||
@@ -20,6 +21,7 @@ impl Lock {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
/// Releases its lock on drop, unless kept until process exit.
|
/// Releases its lock on drop, unless kept until process exit.
|
||||||
|
#[must_use = "bind the guard to hold the lock"]
|
||||||
pub struct Guard {
|
pub struct Guard {
|
||||||
path: Option<String>,
|
path: Option<String>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ pub use locks::*;
|
|||||||
pub trait Interface: 'static {
|
pub trait Interface: 'static {
|
||||||
/// Describe this interface without constructing its value.
|
/// Describe this interface without constructing its value.
|
||||||
fn describe() -> Value
|
fn describe() -> Value
|
||||||
|
where
|
||||||
|
Self: Sized;
|
||||||
|
/// Add this type's methods to shared document generators.
|
||||||
|
#[doc(hidden)]
|
||||||
|
fn add_methods(doc: &mut crate::Discovery)
|
||||||
where
|
where
|
||||||
Self: Sized;
|
Self: Sized;
|
||||||
/// Describe only this type's methods, without endpoint declarations.
|
/// Describe only this type's methods, without endpoint declarations.
|
||||||
@@ -58,7 +63,7 @@ impl Ctx {
|
|||||||
let params = json!({"name":name,"params":params});
|
let params = json!({"name":name,"params":params});
|
||||||
if let Some(sink) = &self.sink {
|
if let Some(sink) = &self.sink {
|
||||||
sink.unbounded_send(json!({"jsonrpc":"2.0","method":"core.notify","params":params}))
|
sink.unbounded_send(json!({"jsonrpc":"2.0","method":"core.notify","params":params}))
|
||||||
.map_err(|_| Error::new(-32002, "endpoint closed"))
|
.map_err(|_| Error::new(-32000, "endpoint closed"))
|
||||||
} else {
|
} else {
|
||||||
notify("core.notify", params)
|
notify("core.notify", params)
|
||||||
}
|
}
|
||||||
@@ -152,7 +157,7 @@ fn send(value: Value) -> Result<()> {
|
|||||||
}
|
}
|
||||||
fn notify(method: &str, params: Value) -> Result<()> {
|
fn notify(method: &str, params: Value) -> Result<()> {
|
||||||
if !STATE.with(|s| s.borrow().active) {
|
if !STATE.with(|s| s.borrow().active) {
|
||||||
return Err(Error::new(-32002, "no core transport"));
|
return Err(Error::new(-32000, "no core transport"));
|
||||||
}
|
}
|
||||||
send(json!({"jsonrpc":"2.0","method":method,"params":params}))
|
send(json!({"jsonrpc":"2.0","method":method,"params":params}))
|
||||||
}
|
}
|
||||||
@@ -376,4 +381,6 @@ async fn serve<T: Interface>(value: T) {
|
|||||||
s.active = false;
|
s.active = false;
|
||||||
s.pending.clear();
|
s.pending.clear();
|
||||||
});
|
});
|
||||||
|
let _ = std::io::stdout().flush();
|
||||||
|
std::process::exit(0);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,3 +5,4 @@ edition = "2024"
|
|||||||
[workspace]
|
[workspace]
|
||||||
[dependencies]
|
[dependencies]
|
||||||
sausage-plugin = { path = "../../.." }
|
sausage-plugin = { path = "../../.." }
|
||||||
|
wstd = "0.6"
|
||||||
|
|||||||
@@ -70,7 +70,11 @@ impl Probe {
|
|||||||
}
|
}
|
||||||
/// Return a deliberate error.
|
/// Return a deliberate error.
|
||||||
fn fail(&self) -> Result<String> {
|
fn fail(&self) -> Result<String> {
|
||||||
Err(sausage_plugin::Error::new(-32010, "deliberate"))
|
Err(sausage_plugin::Error {
|
||||||
|
code: -32010,
|
||||||
|
message: "deliberate".into(),
|
||||||
|
data: Some(json!({"diagnostic":"probe error details"})),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
/// Exercise calls, notifications, and advisory locks.
|
/// Exercise calls, notifications, and advisory locks.
|
||||||
async fn exercise(&self, ctx: &Ctx) -> Result<String> {
|
async fn exercise(&self, ctx: &Ctx) -> Result<String> {
|
||||||
@@ -119,6 +123,19 @@ impl Probe {
|
|||||||
});
|
});
|
||||||
Ok("started".into())
|
Ok("started".into())
|
||||||
}
|
}
|
||||||
|
/// Keep a pollable alive for one hour.
|
||||||
|
fn sleepy(&self) -> Result<()> {
|
||||||
|
sausage_plugin::background(async {
|
||||||
|
wstd::task::sleep(wstd::time::Duration::from_secs(3600)).await;
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
/// Exit after the response despite a pending background pollable.
|
||||||
|
fn sleepy_exit(&self, ctx: &Ctx) -> Result<()> {
|
||||||
|
self.sleepy()?;
|
||||||
|
ctx.exit_after_response();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
/// Save an EOF marker.
|
/// Save an EOF marker.
|
||||||
#[sausage_plugin::on_stop]
|
#[sausage_plugin::on_stop]
|
||||||
async fn stop(&self) {
|
async fn stop(&self) {
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
//! Regression checks for shared schema names and wire identifiers.
|
||||||
|
use sausage_plugin::{Interface, Result, rpc};
|
||||||
|
mod a {
|
||||||
|
/// Text status.
|
||||||
|
#[sausage_plugin::schema]
|
||||||
|
pub struct Status {
|
||||||
|
/// Text value.
|
||||||
|
pub text: String,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mod b {
|
||||||
|
/// Counter status.
|
||||||
|
#[sausage_plugin::schema]
|
||||||
|
pub struct Status {
|
||||||
|
/// Counter value.
|
||||||
|
pub count: u32,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
struct Main;
|
||||||
|
struct Control;
|
||||||
|
#[rpc(endpoints(control = Control))]
|
||||||
|
impl Main {
|
||||||
|
/// Return text status.
|
||||||
|
fn one(&self, p: a::Status) -> Result<a::Status> {
|
||||||
|
Ok(p)
|
||||||
|
}
|
||||||
|
/// Return counter status.
|
||||||
|
fn two(&self, p: b::Status) -> Result<b::Status> {
|
||||||
|
Ok(p)
|
||||||
|
}
|
||||||
|
/// Use an unescaped wire name.
|
||||||
|
fn r#type(&self) -> Result<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[rpc]
|
||||||
|
impl Control {
|
||||||
|
/// Return endpoint counter status.
|
||||||
|
fn status(&self) -> Result<b::Status> {
|
||||||
|
Ok(b::Status { count: 1 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn distinct_status_types_share_document_generators() {
|
||||||
|
let doc = Main::describe();
|
||||||
|
let methods = doc["methods"].as_array().unwrap();
|
||||||
|
let one = methods.iter().find(|m| m["name"] == "one").unwrap();
|
||||||
|
let two = methods.iter().find(|m| m["name"] == "two").unwrap();
|
||||||
|
assert_eq!(one["params"][0]["name"], "text");
|
||||||
|
assert_eq!(two["params"][0]["name"], "count");
|
||||||
|
assert_ne!(one["result"]["schema"], two["result"]["schema"]);
|
||||||
|
for (method, field) in [
|
||||||
|
(one, "text"),
|
||||||
|
(two, "count"),
|
||||||
|
(
|
||||||
|
&doc["x-sausage-endpoints"]["control"]["methods"][0],
|
||||||
|
"count",
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let schema = doc
|
||||||
|
.pointer(
|
||||||
|
method["result"]["schema"]["$ref"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.trim_start_matches('#'),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(schema["properties"].get(field).is_some(), "{doc}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn raw_identifier_has_plain_wire_name() {
|
||||||
|
assert!(
|
||||||
|
Main::describe()["methods"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.any(|m| m["name"] == "type")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn local_transport_errors_have_reserved_sdk_code() {
|
||||||
|
assert_eq!(
|
||||||
|
sausage_plugin::Ctx::default()
|
||||||
|
.notify("x", ())
|
||||||
|
.unwrap_err()
|
||||||
|
.code,
|
||||||
|
-32000
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
use sausage_plugin::{rpc, on_stop, on_retired, Result};
|
||||||
|
struct Stops;
|
||||||
|
#[rpc]
|
||||||
|
impl Stops {
|
||||||
|
/// First stop hook.
|
||||||
|
#[on_stop]
|
||||||
|
fn a(&self) {}
|
||||||
|
/// Second stop hook.
|
||||||
|
#[on_stop]
|
||||||
|
fn b(&self) {}
|
||||||
|
}
|
||||||
|
struct Retires;
|
||||||
|
#[rpc]
|
||||||
|
impl Retires {
|
||||||
|
/// First retired hook.
|
||||||
|
#[on_retired]
|
||||||
|
fn a(&self) -> Result<()> { Ok(()) }
|
||||||
|
/// Second retired hook.
|
||||||
|
#[on_retired]
|
||||||
|
fn b(&self) -> Result<()> { Ok(()) }
|
||||||
|
}
|
||||||
|
fn main() {}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
error: only one on_stop method is allowed
|
||||||
|
--> tests/ui/hooks.rs:8:5
|
||||||
|
|
|
||||||
|
8 | /// Second stop hook.
|
||||||
|
| ^^^^^^^^^^^^^^^^^^^^^
|
||||||
|
|
||||||
|
error: only one on_retired method is allowed
|
||||||
|
--> tests/ui/hooks.rs:18:5
|
||||||
|
|
|
||||||
|
18 | /// Second retired hook.
|
||||||
|
| ^^^^^^^^^^^^^^^^^^^^^^^^
|
||||||
|
|
||||||
|
warning: unused imports: `Result`, `on_retired`, and `on_stop`
|
||||||
|
--> tests/ui/hooks.rs:1:27
|
||||||
|
|
|
||||||
|
1 | use sausage_plugin::{rpc, on_stop, on_retired, Result};
|
||||||
|
| ^^^^^^^ ^^^^^^^^^^ ^^^^^^
|
||||||
|
|
|
||||||
|
= note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
#![deny(unused_must_use)]
|
||||||
|
use sausage_plugin::{call, lock, Endpoint, Guard, TypedCall};
|
||||||
|
fn builders(endpoint: Endpoint) {
|
||||||
|
call("p.m", ());
|
||||||
|
TypedCall::<()>::new(call("p.m", ()));
|
||||||
|
lock("/state/file");
|
||||||
|
endpoint.call::<()>("m", ());
|
||||||
|
}
|
||||||
|
fn guard(g: Guard) -> Guard { g }
|
||||||
|
fn dropped(g: Guard) { guard(g); }
|
||||||
|
fn main() {}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
error: unused `Call` that must be used
|
||||||
|
--> tests/ui/must_use.rs:4:5
|
||||||
|
|
|
||||||
|
4 | call("p.m", ());
|
||||||
|
| ^^^^^^^^^^^^^^^
|
||||||
|
|
|
||||||
|
= note: await the call to send it
|
||||||
|
note: the lint level is defined here
|
||||||
|
--> tests/ui/must_use.rs:1:9
|
||||||
|
|
|
||||||
|
1 | #![deny(unused_must_use)]
|
||||||
|
| ^^^^^^^^^^^^^^^
|
||||||
|
help: use `let _ = ...` to ignore the resulting value
|
||||||
|
|
|
||||||
|
4 | let _ = call("p.m", ());
|
||||||
|
| +++++++
|
||||||
|
|
||||||
|
error: unused `TypedCall` that must be used
|
||||||
|
--> tests/ui/must_use.rs:5:5
|
||||||
|
|
|
||||||
|
5 | TypedCall::<()>::new(call("p.m", ()));
|
||||||
|
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||||
|
|
|
||||||
|
= note: await the call to send it
|
||||||
|
help: use `let _ = ...` to ignore the resulting value
|
||||||
|
|
|
||||||
|
5 | let _ = TypedCall::<()>::new(call("p.m", ()));
|
||||||
|
| +++++++
|
||||||
|
|
||||||
|
error: unused `Lock` that must be used
|
||||||
|
--> tests/ui/must_use.rs:6:5
|
||||||
|
|
|
||||||
|
6 | lock("/state/file");
|
||||||
|
| ^^^^^^^^^^^^^^^^^^^
|
||||||
|
|
|
||||||
|
= note: await the lock to acquire it
|
||||||
|
help: use `let _ = ...` to ignore the resulting value
|
||||||
|
|
|
||||||
|
6 | let _ = lock("/state/file");
|
||||||
|
| +++++++
|
||||||
|
|
||||||
|
error: unused `EndpointCall` that must be used
|
||||||
|
--> tests/ui/must_use.rs:7:5
|
||||||
|
|
|
||||||
|
7 | endpoint.call::<()>("m", ());
|
||||||
|
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||||
|
|
|
||||||
|
= note: await the endpoint call to send it
|
||||||
|
help: use `let _ = ...` to ignore the resulting value
|
||||||
|
|
|
||||||
|
7 | let _ = endpoint.call::<()>("m", ());
|
||||||
|
| +++++++
|
||||||
|
|
||||||
|
error: unused `Guard` that must be used
|
||||||
|
--> tests/ui/must_use.rs:10:24
|
||||||
|
|
|
||||||
|
10 | fn dropped(g: Guard) { guard(g); }
|
||||||
|
| ^^^^^^^^
|
||||||
|
|
|
||||||
|
= note: bind the guard to hold the lock
|
||||||
|
help: use `let _ = ...` to ignore the resulting value
|
||||||
|
|
|
||||||
|
10 | fn dropped(g: Guard) { let _ = guard(g); }
|
||||||
|
| +++++++
|
||||||
@@ -12,7 +12,7 @@ help: the trait `Params` is implemented for `sausage_plugin::Empty`
|
|||||||
note: required by a bound in `sausage_plugin::method`
|
note: required by a bound in `sausage_plugin::method`
|
||||||
--> src/discovery.rs
|
--> src/discovery.rs
|
||||||
|
|
|
|
||||||
| pub fn method<P: Params, R: SchemaType>(doc: &mut Value, name: &str, docs: &str) {
|
| pub fn method<P: Params, R: SchemaType>(doc: &mut Discovery, name: &str, docs: &str) {
|
||||||
| ^^^^^^ required by this bound in `method`
|
| ^^^^^^ required by this bound in `method`
|
||||||
|
|
||||||
error[E0277]: RPC params must be a #[schema] struct with named fields
|
error[E0277]: RPC params must be a #[schema] struct with named fields
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
use sausage_plugin::schema;
|
||||||
|
/// A documented object.
|
||||||
|
#[schema]
|
||||||
|
struct Object {
|
||||||
|
/// A documented field.
|
||||||
|
#[schemars(with = "std::collections::HashMap<String, String>")]
|
||||||
|
value: String,
|
||||||
|
}
|
||||||
|
fn main() {}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
error: schemars attributes are outside the schema subset
|
||||||
|
--> tests/ui/schemars.rs:6:5
|
||||||
|
|
|
||||||
|
6 | #[schemars(with = "std::collections::HashMap<String, String>")]
|
||||||
|
| ^
|
||||||
+102
-5
@@ -11,6 +11,21 @@ struct Process {
|
|||||||
output: BufReader<std::process::ChildStdout>,
|
output: BufReader<std::process::ChildStdout>,
|
||||||
}
|
}
|
||||||
impl Process {
|
impl Process {
|
||||||
|
fn finishes(&mut self) {
|
||||||
|
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(4);
|
||||||
|
loop {
|
||||||
|
if let Some(status) = self.child.try_wait().unwrap() {
|
||||||
|
assert!(status.success());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if std::time::Instant::now() >= deadline {
|
||||||
|
self.child.kill().unwrap();
|
||||||
|
let _ = self.child.wait();
|
||||||
|
panic!("process survived exit or EOF for four seconds");
|
||||||
|
}
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||||
|
}
|
||||||
|
}
|
||||||
fn send(&mut self, v: Value) {
|
fn send(&mut self, v: Value) {
|
||||||
writeln!(self.input.as_mut().unwrap(), "{v}").unwrap();
|
writeln!(self.input.as_mut().unwrap(), "{v}").unwrap();
|
||||||
}
|
}
|
||||||
@@ -76,6 +91,15 @@ fn protocol() {
|
|||||||
let wasm = target.join("wasm32-wasip2/debug/sdk-probe.wasm");
|
let wasm = target.join("wasm32-wasip2/debug/sdk-probe.wasm");
|
||||||
let dir = std::env::temp_dir().join(format!("sausage-sdk-{}", std::process::id()));
|
let dir = std::env::temp_dir().join(format!("sausage-sdk-{}", std::process::id()));
|
||||||
std::fs::create_dir_all(&dir).unwrap();
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
for method in ["sleepy_exit", "sleepy"] {
|
||||||
|
let mut p = start(&wasm, &dir);
|
||||||
|
p.request(method, json!({}));
|
||||||
|
assert_eq!(p.read()["result"], Value::Null);
|
||||||
|
if method == "sleepy" {
|
||||||
|
drop(p.input.take());
|
||||||
|
}
|
||||||
|
p.finishes();
|
||||||
|
}
|
||||||
let mut p = start(&wasm, &dir);
|
let mut p = start(&wasm, &dir);
|
||||||
p.request("rpc.discover", json!({}));
|
p.request("rpc.discover", json!({}));
|
||||||
let d = p.read();
|
let d = p.read();
|
||||||
@@ -170,7 +194,7 @@ fn protocol() {
|
|||||||
p.answer(&outgoing, json!({}));
|
p.answer(&outgoing, json!({}));
|
||||||
assert!(p.child.wait().unwrap().success());
|
assert!(p.child.wait().unwrap().success());
|
||||||
let provider_doc = &d["result"];
|
let provider_doc = &d["result"];
|
||||||
let imports = root.join("tests/plugins/consumer/imports");
|
let imports = target.join("imports");
|
||||||
std::fs::create_dir_all(&imports).unwrap();
|
std::fs::create_dir_all(&imports).unwrap();
|
||||||
std::fs::write(
|
std::fs::write(
|
||||||
imports.join("probe.json"),
|
imports.join("probe.json"),
|
||||||
@@ -237,9 +261,82 @@ fn protocol() {
|
|||||||
.contains("identity mismatch")
|
.contains("identity mismatch")
|
||||||
);
|
);
|
||||||
assert!(client.child.wait().unwrap().success());
|
assert!(client.child.wait().unwrap().success());
|
||||||
std::fs::write(path, original).unwrap();
|
std::fs::write(&path, original).unwrap();
|
||||||
drop(provider.input.take());
|
// The endpoint server ignores non-RPC lines before a request.
|
||||||
assert_eq!(provider.read()["params"]["name"], "stopped");
|
let address = std::fs::read_to_string(&path)
|
||||||
assert!(provider.child.wait().unwrap().success());
|
.unwrap()
|
||||||
|
.split_whitespace()
|
||||||
|
.next()
|
||||||
|
.unwrap()
|
||||||
|
.trim_start_matches("tcp:")
|
||||||
|
.to_owned();
|
||||||
|
let mut stream = std::net::TcpStream::connect(address).unwrap();
|
||||||
|
stream
|
||||||
|
.set_read_timeout(Some(std::time::Duration::from_secs(4)))
|
||||||
|
.unwrap();
|
||||||
|
stream.write_all(b"\nnot-json\n[]\n{\"jsonrpc\":\"2.0\",\"id\":99,\"method\":\"rpc.endpoint\",\"params\":{}}\n").unwrap();
|
||||||
|
let mut reader = BufReader::new(stream);
|
||||||
|
let mut line = String::new();
|
||||||
|
reader.read_line(&mut line).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
sausage_plugin::serde_json::from_str::<Value>(&line).unwrap()["id"],
|
||||||
|
99
|
||||||
|
);
|
||||||
|
reader
|
||||||
|
.get_mut()
|
||||||
|
.write_all(b"{\"jsonrpc\":\"2.0\",\"id\":100,\"method\":\"stop\",\"params\":{}}\n")
|
||||||
|
.unwrap();
|
||||||
|
line.clear();
|
||||||
|
reader.read_line(&mut line).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
sausage_plugin::serde_json::from_str::<Value>(&line).unwrap()["id"],
|
||||||
|
100
|
||||||
|
);
|
||||||
|
provider.finishes();
|
||||||
|
|
||||||
|
// The endpoint client ignores non-RPC lines during verification and responses.
|
||||||
|
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
|
let owner = "probe-00000000-0000-7000-8000-000000000000";
|
||||||
|
std::fs::write(
|
||||||
|
&path,
|
||||||
|
format!("tcp:{} {owner} control\n", listener.local_addr().unwrap()),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let server = std::thread::spawn(move || {
|
||||||
|
for _ in 0..2 {
|
||||||
|
let (stream, _) = listener.accept().unwrap();
|
||||||
|
stream
|
||||||
|
.set_read_timeout(Some(std::time::Duration::from_secs(4)))
|
||||||
|
.unwrap();
|
||||||
|
let mut reader = BufReader::new(stream);
|
||||||
|
loop {
|
||||||
|
let mut line = String::new();
|
||||||
|
if reader.read_line(&mut line).unwrap() == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let request: Value = sausage_plugin::serde_json::from_str(&line).unwrap();
|
||||||
|
let result = if request["method"] == "rpc.endpoint" {
|
||||||
|
json!({"owner":owner,"plugin":"probe","interface":"control"})
|
||||||
|
} else {
|
||||||
|
json!(7)
|
||||||
|
};
|
||||||
|
writeln!(
|
||||||
|
reader.get_mut(),
|
||||||
|
"\nnot-json\n[]\n{}",
|
||||||
|
json!({"jsonrpc":"2.0","id":request["id"],"result":result})
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let mut client = start(&consumer, &dir);
|
||||||
|
client.request("endpoint", json!({}));
|
||||||
|
assert_eq!(client.read()["result"], 7);
|
||||||
|
client.finishes();
|
||||||
|
server.join().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read(root.join("tests/plugins/consumer/imports/probe.json")).unwrap(),
|
||||||
|
include_bytes!("plugins/consumer/imports/probe.json")
|
||||||
|
);
|
||||||
std::fs::remove_dir_all(dir).unwrap();
|
std::fs::remove_dir_all(dir).unwrap();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user