Synced from monorepo
Changes: - Non-blocking coding-data sharing upsell banner - Consolidate remediation in Doctor - Auto mode defers fail-closed gate asks to the classifier - Coalesce marketplace list fetches - Allow removing a marketplace source by name - Contain hung git marketplace sources (timeouts, non-blocking refresh, unbrick modal) - Label failed workspace RPCs with error_kind - Drop redundant explicit tonic/prost deps from xai-grok-shell - Report real exit codes for completed background shells - Narrow the date-rollover reminder to date-bearing templates - Wire toolOverrides through the session and agent - Security: Bash(git:*) allowlist matches whole command chain by prefix - Split prompt-trigger telemetry and record classifier provenance - Raise connectors-manager timeout to 60s - Auto classifier honors recorded approvals for repeat actions - Apply doctor fixes in the TUI - Auto-mode classifier timeouts prompt instead of silently denying - Scope subagent completion drains to the owning session - Add the toolOverrides wire types - Set client_identifier=grok-agent-sdk - Accept both spellings of the workspace-teleport kill switch - Persist one-shot occurrence journal - Stop turns that poll the exact same tool call 16x in a row - Copy compaction checkpoint files when forking sessions - Auto-focus permission prompt from scrollback - Esc cancels the running turn in non-vim and minimal modes - List Ctrl+Z undo and redo in keyboard shortcuts - Out-of-process macOS mic capture - Show active auth mode on session-info - Install the npm binary under $GROK_HOME - Remove hover/click dead zones between dashboard items - Route startup warnings to doctor - Document [feedback.user] author identity config - Extend bang command timeout - Close combine-queued edit-hold race - Integrate relocation recovery - Expose privacy notice rollout flag - Break harness discovery ref cycle so connections can idle-evict - Shift/Alt+Enter inserts newline when editing a queued prompt - Gate project Claude permissions on folder trust - Echo response.create.event_id on response.created - Toast when session creation fails from disk full - Add shared test process lifecycle - Enable dynamic workflows by default - Add relocation transaction state machine - Add shared test sandbox - Surface auth failures on model-switch compact - Persist durable scheduler expiry - Confirm before removing extensions-modal items - Re-run compact and prompt after login when compact hit expired auth - Recap sends hosted tools under backend search
This commit is contained in:
parent
3af4d5d398
commit
a5727c5960
482 changed files with 37627 additions and 13402 deletions
|
|
@ -246,6 +246,12 @@ impl crate::types::resources::ResourceType for BashParams {
|
|||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Product default advertised in the model-facing schema (FG). Not applied as a
|
||||
/// serde default: omit/`None` must remain "use host/FG policy, BG unbounded".
|
||||
fn schema_default_timeout_ms() -> Option<u64> {
|
||||
Some(120_000)
|
||||
}
|
||||
|
||||
/// Input for the bash/terminal command tool.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct BashToolInput {
|
||||
|
|
@ -258,11 +264,14 @@ pub struct BashToolInput {
|
|||
/// the task runs until it exits or is killed via the kill task tool.
|
||||
// keep in sync with the rustdoc above
|
||||
#[schemars(
|
||||
description = "Optional timeout in milliseconds (max 300000). Default: 120000 (2 minutes). `timeout: 0` in background mode disables the wrapper timeout entirely; the task runs until it exits or is killed via the kill task tool."
|
||||
description = "Optional timeout in milliseconds (max 300000). Default: 120000 (2 minutes). `timeout: 0` in background mode disables the wrapper timeout entirely; the task runs until it exits or is killed via the kill task tool.",
|
||||
default = "schema_default_timeout_ms"
|
||||
)]
|
||||
// Some models serialize numeric tool args
|
||||
// as JSON strings (`"120000"`), which a plain `Option<u64>` rejects. Accept
|
||||
// string-or-number here; the schema still advertises an integer.
|
||||
// Serde default stays None so omit ≠ Some(120000): background omit must stay
|
||||
// unbounded (see resolve_effective_timeout). Schema still advertises 120000.
|
||||
#[serde(
|
||||
default,
|
||||
deserialize_with = "crate::types::schema::deserialize_lenient_u64",
|
||||
|
|
@ -2276,6 +2285,34 @@ impl xai_tool_runtime::Tool for BashTool {
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn bash_timeout_schema_defaults_to_120s() {
|
||||
let schema = serde_json::to_value(schemars::schema_for!(BashToolInput)).unwrap();
|
||||
let timeout = &schema["properties"]["timeout"];
|
||||
assert_eq!(
|
||||
timeout.get("default"),
|
||||
Some(&serde_json::json!(120_000)),
|
||||
"timeout schema should advertise default 120000, got {timeout}"
|
||||
);
|
||||
// Serde omit stays None so background without timeout remains unbounded.
|
||||
let missing: BashToolInput =
|
||||
serde_json::from_str(r#"{"command":"ls","description":"list"}"#).unwrap();
|
||||
assert_eq!(missing.timeout, None);
|
||||
let zero: BashToolInput =
|
||||
serde_json::from_str(r#"{"command":"ls","description":"list","timeout":0}"#).unwrap();
|
||||
assert_eq!(zero.timeout, Some(0));
|
||||
// Explicit BG omit still resolves unbounded.
|
||||
assert_eq!(
|
||||
BashTool::resolve_effective_timeout(
|
||||
missing.timeout,
|
||||
true,
|
||||
DEFAULT_TIMEOUT,
|
||||
DEFAULT_MAX_TIMEOUT_MS,
|
||||
),
|
||||
std::time::Duration::MAX
|
||||
);
|
||||
}
|
||||
|
||||
use crate::computer::types::{
|
||||
BackgroundHandle, ComputerError, KillOutcome, TaskSnapshot, TerminalBackend,
|
||||
TerminalRunRequest, TerminalRunResult,
|
||||
|
|
@ -2288,8 +2325,7 @@ mod tests {
|
|||
|
||||
/// Models occasionally serialize numeric tool args as JSON strings. The
|
||||
/// `timeout` field must accept both `120000` and `"120000"`, stay `None`
|
||||
/// when omitted or null. Regression for the `invalid type: string
|
||||
/// "120000", expected u64` failure seen with some models.
|
||||
/// when omitted or null (FG host policy / BG unbounded).
|
||||
#[test]
|
||||
fn timeout_accepts_string_or_integer() {
|
||||
let from_int: BashToolInput =
|
||||
|
|
|
|||
|
|
@ -93,16 +93,13 @@ pub struct GrepSearchInput {
|
|||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub context: Option<usize>,
|
||||
|
||||
#[schemars(
|
||||
rename = "-i",
|
||||
description = "Case insensitive search (rg -i). Defaults to false."
|
||||
)]
|
||||
#[schemars(rename = "-i", description = "Case insensitive search (rg -i).")]
|
||||
#[serde(
|
||||
rename = "-i",
|
||||
default,
|
||||
deserialize_with = "crate::types::schema::deserialize_lenient_option_bool"
|
||||
deserialize_with = "crate::types::schema::deserialize_lenient_bool"
|
||||
)]
|
||||
pub case_insensitive: Option<bool>,
|
||||
pub case_insensitive: bool,
|
||||
|
||||
#[schemars(
|
||||
description = "File type to search (rg --type). Common types: js, py, rust, go, java, etc. More efficient than glob for standard file types."
|
||||
|
|
@ -117,14 +114,13 @@ pub struct GrepSearchInput {
|
|||
pub head_limit: Option<usize>,
|
||||
|
||||
#[schemars(
|
||||
description = "Enable multiline mode where . matches newlines and patterns can span lines (rg -U --multiline-dotall). Default: false."
|
||||
description = "Enable multiline mode where . matches newlines and patterns can span lines (rg -U --multiline-dotall)."
|
||||
)]
|
||||
#[serde(
|
||||
default,
|
||||
deserialize_with = "crate::types::schema::deserialize_lenient_option_bool",
|
||||
skip_serializing_if = "Option::is_none"
|
||||
deserialize_with = "crate::types::schema::deserialize_lenient_bool"
|
||||
)]
|
||||
pub multiline: Option<bool>,
|
||||
pub multiline: bool,
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
|
@ -766,7 +762,7 @@ async fn prepare_grep(
|
|||
.arg("1000")
|
||||
.arg("--max-columns-preview");
|
||||
|
||||
if input.case_insensitive.unwrap_or(false) {
|
||||
if input.case_insensitive {
|
||||
cmd.arg("--ignore-case");
|
||||
}
|
||||
|
||||
|
|
@ -792,7 +788,7 @@ async fn prepare_grep(
|
|||
cmd.arg("--type").arg(t);
|
||||
}
|
||||
|
||||
if input.multiline.unwrap_or(false) {
|
||||
if input.multiline {
|
||||
cmd.arg("-U").arg("--multiline-dotall");
|
||||
}
|
||||
|
||||
|
|
@ -1483,13 +1479,55 @@ mod tests {
|
|||
before_context: None,
|
||||
after_context: None,
|
||||
context: None,
|
||||
case_insensitive: None,
|
||||
case_insensitive: false,
|
||||
r#type: None,
|
||||
head_limit: None,
|
||||
multiline: None,
|
||||
multiline: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Boolean flags must be non-optional in the model-facing schema so the
|
||||
/// default is unambiguous (`false`, not `null` + "Default: false" prose).
|
||||
#[test]
|
||||
fn grep_bool_flags_schema_is_plain_boolean_with_default_false() {
|
||||
let schema = serde_json::to_value(schemars::schema_for!(GrepSearchInput)).unwrap();
|
||||
let props = &schema["properties"];
|
||||
|
||||
// Field is renamed to "-i" for the model-facing name.
|
||||
let case = &props["-i"];
|
||||
assert_eq!(case["type"], "boolean", "case_insensitive schema: {case}");
|
||||
assert_eq!(case["default"], false, "case_insensitive schema: {case}");
|
||||
assert!(
|
||||
case.get("anyOf").is_none(),
|
||||
"must not use nullable anyOf: {case}"
|
||||
);
|
||||
|
||||
let multi = &props["multiline"];
|
||||
assert_eq!(multi["type"], "boolean", "multiline schema: {multi}");
|
||||
assert_eq!(multi["default"], false, "multiline schema: {multi}");
|
||||
assert!(
|
||||
multi.get("anyOf").is_none(),
|
||||
"must not use nullable anyOf: {multi}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grep_bool_flags_deserialize_missing_and_null_as_false() {
|
||||
let missing: GrepSearchInput = serde_json::from_str(r#"{"pattern":"foo"}"#).unwrap();
|
||||
assert!(!missing.case_insensitive);
|
||||
assert!(!missing.multiline);
|
||||
|
||||
let nulls: GrepSearchInput =
|
||||
serde_json::from_str(r#"{"pattern":"foo","-i":null,"multiline":null}"#).unwrap();
|
||||
assert!(!nulls.case_insensitive);
|
||||
assert!(!nulls.multiline);
|
||||
|
||||
let truths: GrepSearchInput =
|
||||
serde_json::from_str(r#"{"pattern":"foo","-i":"yes","multiline":1}"#).unwrap();
|
||||
assert!(truths.case_insensitive);
|
||||
assert!(truths.multiline);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grep_timeout_secs_platform_defaults() {
|
||||
assert_eq!(grep_timeout_secs(false), 20);
|
||||
|
|
@ -2049,10 +2087,10 @@ mod tests {
|
|||
before_context: None,
|
||||
after_context: None,
|
||||
context: None,
|
||||
case_insensitive: None,
|
||||
case_insensitive: false,
|
||||
r#type: None,
|
||||
head_limit: None,
|
||||
multiline: None,
|
||||
multiline: false,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
|
@ -2087,10 +2125,10 @@ mod tests {
|
|||
before_context: None,
|
||||
after_context: None,
|
||||
context: None,
|
||||
case_insensitive: None,
|
||||
case_insensitive: false,
|
||||
r#type: None,
|
||||
head_limit: None,
|
||||
multiline: None,
|
||||
multiline: false,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
|
@ -2123,10 +2161,10 @@ mod tests {
|
|||
before_context: None,
|
||||
after_context: None,
|
||||
context: None,
|
||||
case_insensitive: None,
|
||||
case_insensitive: false,
|
||||
r#type: None,
|
||||
head_limit: None,
|
||||
multiline: None,
|
||||
multiline: false,
|
||||
},
|
||||
)
|
||||
.await
|
||||
|
|
|
|||
|
|
@ -29,6 +29,10 @@ pub const MAX_TIMEOUT_MS: u64 = 36_000_000; // 10 hours
|
|||
/// Max result size for the tool_result response.
|
||||
pub const MAX_RESULT_SIZE_CHARS: usize = 10_000;
|
||||
|
||||
fn default_timeout_ms() -> Option<u64> {
|
||||
Some(DEFAULT_TIMEOUT_MS)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
|
||||
pub struct MonitorInput {
|
||||
/// Shell command or script. Each stdout line is an event; exit ends the watch.
|
||||
|
|
@ -45,9 +49,10 @@ pub struct MonitorInput {
|
|||
|
||||
/// Kill the monitor after this deadline (ms). Ignored when persistent is true.
|
||||
/// Default: 36000000 (10 hr). Max: 36000000 (10 hr).
|
||||
#[serde(default)]
|
||||
#[serde(default = "default_timeout_ms")]
|
||||
#[schemars(
|
||||
description = "Kill the monitor after this deadline (ms). Default: 36000000 (10 hr)."
|
||||
description = "Kill the monitor after this deadline (ms). Default: 36000000 (10 hr). Max: 36000000 (10 hr).",
|
||||
default = "default_timeout_ms"
|
||||
)]
|
||||
pub timeout_ms: Option<u64>,
|
||||
|
||||
|
|
@ -55,12 +60,12 @@ pub struct MonitorInput {
|
|||
/// Stop with kill_command_or_subagent.
|
||||
#[serde(
|
||||
default,
|
||||
deserialize_with = "crate::types::schema::deserialize_lenient_option_bool"
|
||||
deserialize_with = "crate::types::schema::deserialize_lenient_bool"
|
||||
)]
|
||||
#[schemars(
|
||||
description = "Run for the lifetime of the session (no timeout).${%- if tools.by_kind.kill_task_action %} Stop with ${{ tools.by_kind.kill_task_action }}.${%- endif %}"
|
||||
)]
|
||||
pub persistent: Option<bool>,
|
||||
pub persistent: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
|
||||
|
|
@ -85,7 +90,7 @@ pub enum MonitorError {
|
|||
impl MonitorInput {
|
||||
/// Validate input constraints.
|
||||
pub fn validate(&self) -> Result<(), MonitorError> {
|
||||
let persistent = self.persistent.unwrap_or(false);
|
||||
let persistent = self.persistent;
|
||||
if let Some(timeout) = self.timeout_ms
|
||||
&& !persistent
|
||||
&& timeout > MAX_TIMEOUT_MS
|
||||
|
|
@ -97,7 +102,7 @@ impl MonitorInput {
|
|||
|
||||
/// Resolved timeout in milliseconds (0 for persistent / no-deadline monitors).
|
||||
pub fn resolved_timeout_ms(&self) -> u64 {
|
||||
if self.persistent.unwrap_or(false) {
|
||||
if self.persistent {
|
||||
0
|
||||
} else {
|
||||
self.timeout_ms.unwrap_or(DEFAULT_TIMEOUT_MS)
|
||||
|
|
@ -115,7 +120,7 @@ mod tests {
|
|||
command: "tail -f log".into(),
|
||||
description: "watch log".into(),
|
||||
timeout_ms: None,
|
||||
persistent: None,
|
||||
persistent: false,
|
||||
};
|
||||
assert_eq!(input.resolved_timeout_ms(), DEFAULT_TIMEOUT_MS);
|
||||
assert!(input.validate().is_ok());
|
||||
|
|
@ -128,7 +133,7 @@ mod tests {
|
|||
command: "tail -f log".into(),
|
||||
description: "watch log".into(),
|
||||
timeout_ms: None,
|
||||
persistent: Some(true),
|
||||
persistent: true,
|
||||
};
|
||||
assert_eq!(input.resolved_timeout_ms(), 0);
|
||||
assert!(input.validate().is_ok());
|
||||
|
|
@ -140,7 +145,7 @@ mod tests {
|
|||
command: "cmd".into(),
|
||||
description: "desc".into(),
|
||||
timeout_ms: Some(600_000),
|
||||
persistent: None,
|
||||
persistent: false,
|
||||
};
|
||||
assert_eq!(input.resolved_timeout_ms(), 600_000);
|
||||
assert!(input.validate().is_ok());
|
||||
|
|
@ -152,7 +157,7 @@ mod tests {
|
|||
command: "cmd".into(),
|
||||
description: "desc".into(),
|
||||
timeout_ms: Some(MAX_TIMEOUT_MS + 1),
|
||||
persistent: Some(false),
|
||||
persistent: false,
|
||||
};
|
||||
assert!(input.validate().is_err());
|
||||
}
|
||||
|
|
@ -163,7 +168,7 @@ mod tests {
|
|||
command: "cmd".into(),
|
||||
description: "desc".into(),
|
||||
timeout_ms: Some(MAX_TIMEOUT_MS + 1),
|
||||
persistent: Some(true),
|
||||
persistent: true,
|
||||
};
|
||||
assert!(input.validate().is_ok());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -108,6 +108,10 @@ Usage:
|
|||
- Results are returned with line numbers starting at 1. The format is: LINE_NUMBER→LINE_CONTENT
|
||||
- This tool can read PDF files (.pdf), PowerPoint files (.pptx), Jupyter notebooks (.ipynb files), and image files (e.g. PNG, JPG, etc).
|
||||
- When reading an image file the contents are presented visually as this tool uses multimodal LLMs."#;
|
||||
/// Schema-only advertised default (runtime still treats omit as line 1 via unwrap_or).
|
||||
fn schema_default_offset() -> Option<i64> {
|
||||
Some(1)
|
||||
}
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
|
||||
pub struct ReadFileInput {
|
||||
#[serde(rename = "target_file")]
|
||||
|
|
@ -122,6 +126,7 @@ pub struct ReadFileInput {
|
|||
)]
|
||||
#[schemars(
|
||||
with = "GrokIntegerSchema",
|
||||
default = "schema_default_offset",
|
||||
description = "The line number to start reading from. Only provide if the file is too large to read at once."
|
||||
)]
|
||||
pub offset: Option<i64>,
|
||||
|
|
@ -444,9 +449,10 @@ pub(crate) async fn run_read_file(
|
|||
}
|
||||
if crate::util::binary::is_binary(&extension, &file_bytes) {
|
||||
tracing::info!(
|
||||
path = % path.display(), extension = % extension, detected_by = if crate
|
||||
::util::binary::BINARY_EXTENSIONS.binary_search(& extension.as_str()).is_ok()
|
||||
{ "extension" } else { "content_inspection" },
|
||||
path = %path.display(),
|
||||
extension = %extension,
|
||||
detected_by = if crate::util::binary::BINARY_EXTENSIONS
|
||||
.binary_search(&extension.as_str()).is_ok() { "extension" } else { "content_inspection" },
|
||||
"binary file rejected by read_file"
|
||||
);
|
||||
return Ok(ReadFileOutput::FileReadError(format!(
|
||||
|
|
@ -625,26 +631,51 @@ impl xai_tool_runtime::Tool for ReadFileTool {
|
|||
let Some(spec) = admitted_spec else {
|
||||
let this = ReadFileTool;
|
||||
return Box::pin(async_stream::stream! {
|
||||
yield xai_tool_runtime::ToolStreamItem::Terminal(this.run(ctx, input)
|
||||
. await);
|
||||
yield xai_tool_runtime::ToolStreamItem::Terminal(this.run(ctx, input).await);
|
||||
});
|
||||
};
|
||||
Box::pin(async_stream::stream! {
|
||||
match ReadFileTool::read_with_streamability(& ctx, input). await {
|
||||
Ok((output, streamable)) => { if streamable && let
|
||||
ReadFileOutput::FileContent(fc) = & output && ! fc.content.is_empty() {
|
||||
let content = fc.content.as_bytes(); let mut last_total : u64 = 0; let
|
||||
mut window_start = 0usize; while window_start < content.len() { let mut
|
||||
window_end = (window_start + STREAM_DELTA_TARGET_BYTES).min(content
|
||||
.len()); while window_end > window_start && ! fc.content
|
||||
.is_char_boundary(window_end) { window_end -= 1; }
|
||||
if let Some(p) =
|
||||
xai_tool_runtime::stream_chunk(spec, & content[..window_end], window_end
|
||||
as u64, & mut last_total, false,) { yield
|
||||
xai_tool_runtime::ToolStreamItem::Progress(p); } window_start =
|
||||
window_end; } } yield
|
||||
xai_tool_runtime::ToolStreamItem::Terminal(Ok(output)); } Err(e) => yield
|
||||
xai_tool_runtime::ToolStreamItem::Terminal(Err(e)), }
|
||||
// `streamable` is call-local to this read.
|
||||
match ReadFileTool::read_with_streamability(&ctx, input).await {
|
||||
Ok((output, streamable)) => {
|
||||
if streamable
|
||||
&& let ReadFileOutput::FileContent(fc) = &output
|
||||
&& !fc.content.is_empty()
|
||||
{
|
||||
// Replay char-aligned slices of the final `content`
|
||||
// (each below the 16 KiB cap; see
|
||||
// STREAM_DELTA_TARGET_BYTES).
|
||||
let content = fc.content.as_bytes();
|
||||
let mut last_total: u64 = 0;
|
||||
let mut window_start = 0usize;
|
||||
while window_start < content.len() {
|
||||
let mut window_end =
|
||||
(window_start + STREAM_DELTA_TARGET_BYTES).min(content.len());
|
||||
// Align DOWN to a char boundary (a char is ≤ 4
|
||||
// bytes vs the 4 KiB target: never a zero-width
|
||||
// window).
|
||||
while window_end > window_start
|
||||
&& !fc.content.is_char_boundary(window_end)
|
||||
{
|
||||
window_end -= 1;
|
||||
}
|
||||
if let Some(p) = xai_tool_runtime::stream_chunk(
|
||||
spec,
|
||||
&content[..window_end],
|
||||
window_end as u64,
|
||||
&mut last_total,
|
||||
// Full replay, no streaming loss ⇒ never truncated.
|
||||
false,
|
||||
) {
|
||||
yield xai_tool_runtime::ToolStreamItem::Progress(p);
|
||||
}
|
||||
window_start = window_end;
|
||||
}
|
||||
}
|
||||
yield xai_tool_runtime::ToolStreamItem::Terminal(Ok(output));
|
||||
}
|
||||
Err(e) => yield xai_tool_runtime::ToolStreamItem::Terminal(Err(e)),
|
||||
}
|
||||
})
|
||||
}
|
||||
#[tracing::instrument(name = "tool.read_file", skip_all, fields(path = %input.path))]
|
||||
|
|
@ -2385,12 +2416,17 @@ pub fn verify(req: &HttpRequest) -> Result<Claims, Error> {
|
|||
}
|
||||
}
|
||||
#[test]
|
||||
fn read_file_offset_description_unchanged() {
|
||||
fn read_file_offset_schema_advertises_start_default() {
|
||||
let src = include_str!("mod.rs");
|
||||
assert!(
|
||||
src
|
||||
.contains("description = \"The line number to start reading from. Only provide if the file is too large to read at once.\""),
|
||||
"offset schemars description must not change"
|
||||
src.contains(
|
||||
"description = \"The line number to start reading from. Only provide if the file is too large to read at once.\""
|
||||
),
|
||||
"offset schemars description must remain the pre-PR wording"
|
||||
);
|
||||
assert!(
|
||||
src.contains("default = \"schema_default_offset\""),
|
||||
"offset must advertise schema_default_offset"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
use std::collections::HashSet;
|
||||
use std::time::Duration;
|
||||
|
||||
use chrono::Utc;
|
||||
|
|
@ -31,6 +32,12 @@ enum LoopFireOutcome {
|
|||
Skipped,
|
||||
}
|
||||
|
||||
enum ExpiryPersistenceOutcome {
|
||||
Committed,
|
||||
NotCommitted(std::io::Error),
|
||||
Unknown(SchedulerError),
|
||||
}
|
||||
|
||||
pub(crate) struct PendingDurableRemoval {
|
||||
task_id: String,
|
||||
reservation: super::types::SchedulerReservation,
|
||||
|
|
@ -97,6 +104,7 @@ pub struct SchedulerActor {
|
|||
pub(crate) cancel_token: CancellationToken,
|
||||
pub(crate) clock: SchedulerClock,
|
||||
pub(crate) pending_removal: Option<PendingDurableRemoval>,
|
||||
pub(crate) blocked_expiries: HashSet<String>,
|
||||
}
|
||||
|
||||
impl SchedulerActor {
|
||||
|
|
@ -141,11 +149,18 @@ impl SchedulerActor {
|
|||
|
||||
async fn complete_pending_removal(&mut self) -> Result<bool, SchedulerError> {
|
||||
if self.notification_handle.durable_targets() == DurableNotificationTargets::None {
|
||||
// Immutable targets cannot recover; abandon only the uncommitted reservation.
|
||||
self.pending_removal = None;
|
||||
// Targets are immutable, so retaining the reservation would wedge all later commands.
|
||||
let task_id = self
|
||||
.pending_removal
|
||||
.take()
|
||||
.expect("pending durable removal exists")
|
||||
.task_id;
|
||||
tracing::error!(%task_id, "Durable scheduler removal unavailable");
|
||||
return Err(SchedulerError::NoDurableNotificationConsumer);
|
||||
}
|
||||
|
||||
self.persist_resources().await?;
|
||||
|
||||
let (task_id, version) = {
|
||||
let pending = self
|
||||
.pending_removal
|
||||
|
|
@ -188,13 +203,11 @@ impl SchedulerActor {
|
|||
}
|
||||
}
|
||||
|
||||
let task_ids = {
|
||||
let mut res = self.resources.lock().await;
|
||||
res.get_or_default::<State<SchedulerState>>()
|
||||
.tasks
|
||||
.drain(..)
|
||||
.map(|task| task.id)
|
||||
.collect::<Vec<_>>()
|
||||
let task_ids: Vec<String> = {
|
||||
let res = self.resources.lock().await;
|
||||
res.get::<State<SchedulerState>>()
|
||||
.map(|state| state.tasks.iter().map(|task| task.id.clone()).collect())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
if task_ids.is_empty() {
|
||||
return;
|
||||
|
|
@ -246,7 +259,8 @@ impl SchedulerActor {
|
|||
.map(|s| {
|
||||
s.tasks
|
||||
.iter()
|
||||
.map(|t| t.next_fire_at())
|
||||
.filter(|task| !self.blocked_expiries.contains(&task.id))
|
||||
.map(ScheduledTask::next_fire_at)
|
||||
.min()
|
||||
.map(|next| {
|
||||
let now = Utc::now();
|
||||
|
|
@ -266,7 +280,9 @@ impl SchedulerActor {
|
|||
let now = Utc::now();
|
||||
let mut res = self.resources.lock().await;
|
||||
let state = res.get_or_default::<State<SchedulerState>>();
|
||||
let idx = state.tasks.iter().position(|t| t.next_fire_at() <= now);
|
||||
let idx = state.tasks.iter().position(|task| {
|
||||
task.next_fire_at() <= now && !self.blocked_expiries.contains(&task.id)
|
||||
});
|
||||
|
||||
let Some(idx) = idx else {
|
||||
return;
|
||||
|
|
@ -278,6 +294,7 @@ impl SchedulerActor {
|
|||
let should_remove = !task.recurring;
|
||||
let prompt = task.prompt.clone();
|
||||
let human_schedule = interval_to_human(task.interval_secs);
|
||||
let is_durable = task.durable;
|
||||
let foreground = task.foreground;
|
||||
let last_subagent_id = task.last_subagent_id.clone();
|
||||
let iterations_since_fresh = task.iterations_since_fresh;
|
||||
|
|
@ -290,6 +307,89 @@ impl SchedulerActor {
|
|||
1
|
||||
};
|
||||
let transition = if is_expired { "expiry" } else { "fire" };
|
||||
|
||||
if is_expired && is_durable {
|
||||
if self.notification_handle.durable_targets() == DurableNotificationTargets::None {
|
||||
tracing::error!(%task_id, "Durable scheduler expiry unavailable");
|
||||
self.blocked_expiries.insert(task_id);
|
||||
return;
|
||||
}
|
||||
let mut reservation = self.clock.prepare_transition(1);
|
||||
let expired_task = state.tasks.remove(idx);
|
||||
let acknowledgement = self
|
||||
.resources_persistence
|
||||
.enqueue_save_and_flush(res.serialize());
|
||||
drop(res);
|
||||
tracing::info!(task_id = %task_id, "Scheduled task expired; removing without firing");
|
||||
|
||||
let persistence = match acknowledgement {
|
||||
Ok(acknowledgement) => {
|
||||
let deadline = tokio::time::Instant::now() + DURABILITY_BARRIER_TIMEOUT;
|
||||
tokio::select! {
|
||||
_ = self.cancel_token.cancelled() => {
|
||||
ExpiryPersistenceOutcome::Unknown(SchedulerError::Cancelled)
|
||||
}
|
||||
result = tokio::time::timeout_at(deadline, acknowledgement) => {
|
||||
match result {
|
||||
Ok(Ok(Ok(()))) => ExpiryPersistenceOutcome::Committed,
|
||||
Ok(Ok(Err(error))) => {
|
||||
ExpiryPersistenceOutcome::NotCommitted(error)
|
||||
}
|
||||
Ok(Err(_)) => ExpiryPersistenceOutcome::Unknown(
|
||||
SchedulerError::Persistence(std::io::Error::new(
|
||||
std::io::ErrorKind::BrokenPipe,
|
||||
"resources persistence writer dropped acknowledgement",
|
||||
)),
|
||||
),
|
||||
Err(_) => {
|
||||
ExpiryPersistenceOutcome::Unknown(SchedulerError::Timeout)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(error) => ExpiryPersistenceOutcome::NotCommitted(error),
|
||||
};
|
||||
match persistence {
|
||||
ExpiryPersistenceOutcome::Committed => {}
|
||||
ExpiryPersistenceOutcome::NotCommitted(error) => {
|
||||
tracing::warn!(
|
||||
%task_id,
|
||||
%error,
|
||||
"Durable scheduler expiry was not persisted"
|
||||
);
|
||||
let mut resources = self.resources.lock().await;
|
||||
resources
|
||||
.get_or_default::<State<SchedulerState>>()
|
||||
.tasks
|
||||
.insert(idx, expired_task);
|
||||
self.blocked_expiries.insert(task_id);
|
||||
return;
|
||||
}
|
||||
ExpiryPersistenceOutcome::Unknown(error) => {
|
||||
tracing::warn!(
|
||||
%task_id,
|
||||
%error,
|
||||
"Durable scheduler expiry persistence outcome is unknown"
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let version = reservation.version_at(0);
|
||||
if let Err(error) = self.publish_durable_removal(task_id.clone(), version).await {
|
||||
tracing::warn!(
|
||||
%task_id,
|
||||
%error,
|
||||
"Failed to acknowledge durable scheduler expiry"
|
||||
);
|
||||
return;
|
||||
}
|
||||
let commit = reservation.commit_next(&mut self.clock);
|
||||
log_rollover(transition, Some(&task_id), commit.rollover);
|
||||
return;
|
||||
}
|
||||
|
||||
let mut reservation = self.clock.prepare_transition(transition_count);
|
||||
|
||||
if is_expired {
|
||||
|
|
@ -825,6 +925,7 @@ mod tests {
|
|||
cancel_token: cancel_token.clone(),
|
||||
clock: SchedulerClock::new(),
|
||||
pending_removal: None,
|
||||
blocked_expiries: HashSet::new(),
|
||||
}
|
||||
.run(),
|
||||
);
|
||||
|
|
@ -852,6 +953,22 @@ mod tests {
|
|||
.expect("notification channel closed")
|
||||
}
|
||||
|
||||
fn expired_task(id: &str, durable: bool) -> ScheduledTask {
|
||||
let mut task = ScheduledTask::new(1, id.into(), true, durable);
|
||||
task.id = id.into();
|
||||
task.created_at = Utc::now() - chrono::Duration::seconds(10);
|
||||
task.expires_at = Some(Utc::now() - chrono::Duration::seconds(1));
|
||||
task.foreground = true;
|
||||
task
|
||||
}
|
||||
|
||||
fn due_one_shot(id: &str) -> ScheduledTask {
|
||||
let mut task = ScheduledTask::new(1, id.into(), false, false);
|
||||
task.id = id.into();
|
||||
task.created_at = Utc::now() - chrono::Duration::seconds(10);
|
||||
task
|
||||
}
|
||||
|
||||
fn auto_acknowledged_notifications() -> (
|
||||
ToolNotificationHandle,
|
||||
mpsc::UnboundedReceiver<ToolNotification>,
|
||||
|
|
@ -889,6 +1006,7 @@ mod tests {
|
|||
cancel_token: CancellationToken::new(),
|
||||
clock: SchedulerClock::at_revision_for_test(revision),
|
||||
pending_removal: None,
|
||||
blocked_expiries: HashSet::new(),
|
||||
},
|
||||
notifications,
|
||||
)
|
||||
|
|
@ -915,6 +1033,7 @@ mod tests {
|
|||
cancel_token: cancel_token.clone(),
|
||||
clock: SchedulerClock::new(),
|
||||
pending_removal: None,
|
||||
blocked_expiries: HashSet::new(),
|
||||
};
|
||||
|
||||
tokio::spawn(actor.run());
|
||||
|
|
@ -1232,6 +1351,7 @@ mod tests {
|
|||
cancel_token: cancel_token.clone(),
|
||||
clock: SchedulerClock::new(),
|
||||
pending_removal: None,
|
||||
blocked_expiries: HashSet::new(),
|
||||
};
|
||||
|
||||
tokio::spawn(actor.run());
|
||||
|
|
@ -1318,6 +1438,7 @@ mod tests {
|
|||
cancel_token: cancel_token.clone(),
|
||||
clock: SchedulerClock::new(),
|
||||
pending_removal: None,
|
||||
blocked_expiries: HashSet::new(),
|
||||
};
|
||||
tokio::spawn(actor.run());
|
||||
|
||||
|
|
@ -1523,7 +1644,7 @@ mod tests {
|
|||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancel_sends_removed_for_remaining_tasks_and_drains_state() {
|
||||
async fn cancel_sends_removed_for_remaining_tasks_without_draining_state() {
|
||||
let mut resources = Resources::new();
|
||||
resources.register_state::<SchedulerState>();
|
||||
|
||||
|
|
@ -1548,6 +1669,7 @@ mod tests {
|
|||
cancel_token: cancel_token.clone(),
|
||||
clock: SchedulerClock::new(),
|
||||
pending_removal: None,
|
||||
blocked_expiries: HashSet::new(),
|
||||
};
|
||||
|
||||
let handle = tokio::spawn(actor.run());
|
||||
|
|
@ -1568,14 +1690,15 @@ mod tests {
|
|||
}
|
||||
removed_ids.sort();
|
||||
assert_eq!(removed_ids, vec!["cancel-A", "cancel-B"]);
|
||||
assert!(
|
||||
assert_eq!(
|
||||
shared
|
||||
.lock()
|
||||
.await
|
||||
.get::<State<SchedulerState>>()
|
||||
.unwrap()
|
||||
.tasks
|
||||
.is_empty()
|
||||
.len(),
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1611,6 +1734,7 @@ mod tests {
|
|||
cancel_token: cancel_token.clone(),
|
||||
clock: SchedulerClock::at_revision_for_test(revision),
|
||||
pending_removal: None,
|
||||
blocked_expiries: HashSet::new(),
|
||||
};
|
||||
tokio::spawn(actor.run());
|
||||
|
||||
|
|
@ -2091,6 +2215,7 @@ mod tests {
|
|||
cancel_token: cancel_token.clone(),
|
||||
clock: SchedulerClock::new(),
|
||||
pending_removal: None,
|
||||
blocked_expiries: HashSet::new(),
|
||||
}
|
||||
.run(),
|
||||
);
|
||||
|
|
@ -2433,6 +2558,233 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn durable_expiry_persists_before_ack_and_commits_version() {
|
||||
let (persistence, mut saves) = crate::persistence::ResourcesPersistence::controlled();
|
||||
let mut actor = make_boundary_actor(vec![expired_task("expired", true)], 0).0;
|
||||
actor.resources_persistence = Arc::new(persistence);
|
||||
let (notification_handle, mut notifications) =
|
||||
ToolNotificationHandle::acknowledged_channel();
|
||||
actor.notification_handle = notification_handle;
|
||||
|
||||
{
|
||||
let expiry = actor.fire_next_task();
|
||||
tokio::pin!(expiry);
|
||||
let (snapshot, persisted) = tokio::select! {
|
||||
_ = expiry.as_mut() => panic!("expiry must wait for resource persistence"),
|
||||
save = next_event(&mut saves) => save,
|
||||
};
|
||||
assert_eq!(
|
||||
snapshot["state"]["grok_build.Scheduler"]["tasks"],
|
||||
serde_json::json!([])
|
||||
);
|
||||
assert!(notifications.try_recv().is_err());
|
||||
persisted.send(Ok(())).unwrap();
|
||||
let delivery = tokio::select! {
|
||||
_ = expiry.as_mut() => panic!("expiry must wait for tombstone acknowledgement"),
|
||||
delivery = next_acknowledged(&mut notifications) => delivery,
|
||||
};
|
||||
let removed = notification!(delivery.notification, ScheduledTaskRemoved);
|
||||
assert_eq!(removed.revision, 1);
|
||||
delivery.acknowledgement.unwrap().send(Ok(())).unwrap();
|
||||
tokio::time::timeout(Duration::from_secs(1), expiry.as_mut())
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
assert_eq!(actor.clock.snapshot().revision(), 1);
|
||||
assert!(
|
||||
actor
|
||||
.resources
|
||||
.lock()
|
||||
.await
|
||||
.get::<State<SchedulerState>>()
|
||||
.unwrap()
|
||||
.tasks
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn expiry_persistence_failure_restores_and_blocks_while_plain_task_fires() {
|
||||
let (persistence, mut saves) = crate::persistence::ResourcesPersistence::controlled();
|
||||
let tasks = vec![expired_task("expired", true), due_one_shot("plain")];
|
||||
let (mut actor, mut notifications) = make_boundary_actor(tasks, 0);
|
||||
actor.resources_persistence = Arc::new(persistence);
|
||||
|
||||
{
|
||||
let expiry = actor.fire_next_task();
|
||||
tokio::pin!(expiry);
|
||||
let (_, persisted) = tokio::select! {
|
||||
_ = expiry.as_mut() => panic!("expiry must wait for resource persistence"),
|
||||
save = next_event(&mut saves) => save,
|
||||
};
|
||||
persisted
|
||||
.send(Err(std::io::Error::other("disk unavailable")))
|
||||
.unwrap();
|
||||
tokio::time::timeout(Duration::from_secs(1), expiry.as_mut())
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
assert!(actor.blocked_expiries.contains("expired"));
|
||||
assert_eq!(actor.clock.snapshot().revision(), 0);
|
||||
assert!(notifications.try_recv().is_err());
|
||||
|
||||
actor.fire_next_task().await;
|
||||
assert_eq!(
|
||||
(
|
||||
notification!(notifications.try_recv().unwrap(), ScheduledTaskFired).task_id,
|
||||
notification!(notifications.try_recv().unwrap(), ScheduledTaskRemoved).task_id,
|
||||
),
|
||||
("plain".to_string(), "plain".to_string())
|
||||
);
|
||||
actor.fire_next_task().await;
|
||||
assert!(saves.try_recv().is_err());
|
||||
assert!(notifications.try_recv().is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn expiry_without_durable_target_blocks_only_expiry() {
|
||||
let tasks = vec![expired_task("expired", true), due_one_shot("plain")];
|
||||
let (mut actor, _) = make_boundary_actor(tasks, 0);
|
||||
let (notification_handle, mut notifications) = ToolNotificationHandle::channel();
|
||||
actor.notification_handle = notification_handle;
|
||||
|
||||
actor.fire_next_task().await;
|
||||
assert!(actor.blocked_expiries.contains("expired"));
|
||||
assert!(notifications.try_recv().is_err());
|
||||
actor.fire_next_task().await;
|
||||
assert_eq!(
|
||||
(
|
||||
notification!(notifications.try_recv().unwrap(), ScheduledTaskFired).task_id,
|
||||
notification!(notifications.try_recv().unwrap(), ScheduledTaskRemoved).task_id,
|
||||
),
|
||||
("plain".to_string(), "plain".to_string())
|
||||
);
|
||||
actor.fire_next_task().await;
|
||||
assert!(notifications.try_recv().is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn expiry_ack_failure_leaves_absent_and_continues_without_version_commit() {
|
||||
let tasks = vec![expired_task("expired", true), due_one_shot("plain")];
|
||||
let mut actor = make_boundary_actor(tasks, 0).0;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let state_path = dir.path().join("resources_state.json");
|
||||
actor.resources_persistence = Arc::new(crate::persistence::ResourcesPersistence::new(
|
||||
state_path.clone(),
|
||||
));
|
||||
let (notification_handle, mut notifications) =
|
||||
ToolNotificationHandle::acknowledged_channel();
|
||||
actor.notification_handle = notification_handle;
|
||||
|
||||
let removed_revision = {
|
||||
let expiry = actor.fire_next_task();
|
||||
tokio::pin!(expiry);
|
||||
let delivery = tokio::select! {
|
||||
_ = expiry.as_mut() => panic!("expiry must wait for tombstone acknowledgement"),
|
||||
delivery = next_acknowledged(&mut notifications) => delivery,
|
||||
};
|
||||
let removed = notification!(delivery.notification, ScheduledTaskRemoved);
|
||||
let removed_revision = removed.revision;
|
||||
delivery
|
||||
.acknowledgement
|
||||
.unwrap()
|
||||
.send(Err("append failed".into()))
|
||||
.unwrap();
|
||||
tokio::time::timeout(Duration::from_secs(1), expiry.as_mut())
|
||||
.await
|
||||
.unwrap();
|
||||
removed_revision
|
||||
};
|
||||
assert_eq!(actor.clock.snapshot().revision(), 0);
|
||||
assert!(
|
||||
actor
|
||||
.resources
|
||||
.lock()
|
||||
.await
|
||||
.get::<State<SchedulerState>>()
|
||||
.unwrap()
|
||||
.tasks
|
||||
.iter()
|
||||
.all(|task| task.id != "expired")
|
||||
);
|
||||
let persisted: serde_json::Value =
|
||||
serde_json::from_str(&std::fs::read_to_string(state_path).unwrap()).unwrap();
|
||||
assert!(
|
||||
persisted["state"]["grok_build.Scheduler"]["tasks"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.all(|task| task["id"] != "expired")
|
||||
);
|
||||
|
||||
actor.fire_next_task().await;
|
||||
assert_eq!(
|
||||
notification!(
|
||||
next_acknowledged(&mut notifications).await.notification,
|
||||
ScheduledTaskFired
|
||||
)
|
||||
.task_id,
|
||||
"plain"
|
||||
);
|
||||
let plain_removed = next_acknowledged(&mut notifications).await;
|
||||
assert!(plain_removed.acknowledgement.is_none());
|
||||
assert_eq!(
|
||||
notification!(plain_removed.notification, ScheduledTaskRemoved).revision,
|
||||
removed_revision + 1
|
||||
);
|
||||
actor.fire_next_task().await;
|
||||
assert!(notifications.try_recv().is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn expiry_persistence_cancellation_leaves_absent_and_stops_actor() {
|
||||
let (persistence, mut saves) = crate::persistence::ResourcesPersistence::controlled();
|
||||
let mut resources = Resources::new();
|
||||
resources.register_state::<SchedulerState>();
|
||||
resources.get_or_default::<State<SchedulerState>>().tasks =
|
||||
vec![expired_task("expired", true)];
|
||||
let shared = Arc::new(Mutex::new(resources));
|
||||
let (notification_handle, mut notifications) =
|
||||
ToolNotificationHandle::acknowledged_channel();
|
||||
let (_cmd_tx, cmd_rx) = mpsc::unbounded_channel();
|
||||
let cancel_token = CancellationToken::new();
|
||||
let actor = SchedulerActor {
|
||||
resources: shared.clone(),
|
||||
resources_persistence: Arc::new(persistence),
|
||||
notification_handle,
|
||||
cmd_rx,
|
||||
cancel_token: cancel_token.clone(),
|
||||
clock: SchedulerClock::new(),
|
||||
pending_removal: None,
|
||||
blocked_expiries: HashSet::new(),
|
||||
};
|
||||
let actor_task = tokio::spawn(actor.run());
|
||||
let (_, _withheld) = next_event(&mut saves).await;
|
||||
let announced = next_acknowledged(&mut notifications).await;
|
||||
assert!(announced.acknowledgement.is_none());
|
||||
assert!(matches!(
|
||||
announced.notification,
|
||||
ToolNotification::ScheduledTaskCreated(_)
|
||||
));
|
||||
assert!(!actor_task.is_finished());
|
||||
cancel_token.cancel();
|
||||
tokio::time::timeout(Duration::from_secs(1), actor_task)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(
|
||||
shared
|
||||
.lock()
|
||||
.await
|
||||
.get::<State<SchedulerState>>()
|
||||
.unwrap()
|
||||
.tasks
|
||||
.is_empty()
|
||||
);
|
||||
assert!(notifications.try_recv().is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancel_with_no_tasks_sends_no_removed() {
|
||||
let mut resources = Resources::new();
|
||||
|
|
@ -2451,6 +2803,7 @@ mod tests {
|
|||
cancel_token: cancel_token.clone(),
|
||||
clock: SchedulerClock::new(),
|
||||
pending_removal: None,
|
||||
blocked_expiries: HashSet::new(),
|
||||
};
|
||||
|
||||
let handle = tokio::spawn(actor.run());
|
||||
|
|
|
|||
|
|
@ -302,6 +302,7 @@ mod tests {
|
|||
cancel_token: cancel_token.clone(),
|
||||
clock: Default::default(),
|
||||
pending_removal: None,
|
||||
blocked_expiries: Default::default(),
|
||||
};
|
||||
tokio::spawn(actor.run());
|
||||
(shared, cancel_token)
|
||||
|
|
|
|||
|
|
@ -3,4 +3,5 @@ pub mod create;
|
|||
pub mod delete;
|
||||
pub mod interval;
|
||||
pub mod list;
|
||||
pub(crate) mod occurrence_journal;
|
||||
pub mod types;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,566 @@
|
|||
//! Persisted one-shot removal receipts and restart reconciliation.
|
||||
//!
|
||||
//! A receipt records task absence and exact fire/removal versions in one JSON resources
|
||||
//! snapshot. Recovery is a pure plan: it reports removals requiring persistence and
|
||||
//! timer suppression while all state mutation/publication remains in the actor layer.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use super::types::{ScheduledTask, SchedulerState, SchedulerVersion};
|
||||
|
||||
pub(super) const MAX_PENDING_ONE_SHOTS: usize = 50;
|
||||
const MAX_QUARANTINED_TASK_IDS: usize = 50;
|
||||
const MAX_TASK_ID_BYTES: usize = 256;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
|
||||
#[serde(transparent)]
|
||||
pub(crate) struct ScheduledOccurrenceId(uuid::Uuid);
|
||||
|
||||
impl ScheduledOccurrenceId {
|
||||
fn new() -> Self {
|
||||
Self(uuid::Uuid::now_v7())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for ScheduledOccurrenceId {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let id = uuid::Uuid::deserialize(deserializer)?;
|
||||
if id.get_version() != Some(uuid::Version::SortRand)
|
||||
|| id.get_variant() != uuid::Variant::RFC4122
|
||||
{
|
||||
return Err(serde::de::Error::custom(
|
||||
"scheduled occurrence identity must be an RFC UUIDv7",
|
||||
));
|
||||
}
|
||||
Ok(Self(id))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct ScheduledOccurrenceVersions {
|
||||
fire: SchedulerVersion,
|
||||
removal: SchedulerVersion,
|
||||
}
|
||||
|
||||
impl ScheduledOccurrenceVersions {
|
||||
pub(super) fn try_new(
|
||||
fire: SchedulerVersion,
|
||||
removal: SchedulerVersion,
|
||||
) -> Result<Self, OccurrenceJournalError> {
|
||||
let generation = fire.generation_id();
|
||||
if generation.get_version() != Some(uuid::Version::SortRand)
|
||||
|| generation.get_variant() != uuid::Variant::RFC4122
|
||||
|| fire.revision() == 0
|
||||
|| removal.generation_id() != generation
|
||||
|| fire
|
||||
.revision()
|
||||
.checked_add(1)
|
||||
.is_none_or(|revision| removal.revision() != revision)
|
||||
{
|
||||
return Err(OccurrenceJournalError::InvalidVersions);
|
||||
}
|
||||
Ok(Self { fire, removal })
|
||||
}
|
||||
|
||||
pub(super) fn fire(self) -> SchedulerVersion {
|
||||
self.fire
|
||||
}
|
||||
|
||||
pub(super) fn removal(self) -> SchedulerVersion {
|
||||
self.removal
|
||||
}
|
||||
|
||||
fn contains(self, version: SchedulerVersion) -> bool {
|
||||
self.fire == version || self.removal == version
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for ScheduledOccurrenceVersions {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct PersistedVersions {
|
||||
fire: SchedulerVersion,
|
||||
removal: SchedulerVersion,
|
||||
}
|
||||
|
||||
let persisted = PersistedVersions::deserialize(deserializer)?;
|
||||
Self::try_new(persisted.fire, persisted.removal).map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct OneShotOccurrence {
|
||||
occurrence_id: ScheduledOccurrenceId,
|
||||
task: ScheduledTask,
|
||||
versions: ScheduledOccurrenceVersions,
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for OneShotOccurrence {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct PersistedOccurrence {
|
||||
occurrence_id: ScheduledOccurrenceId,
|
||||
task: ScheduledTask,
|
||||
versions: ScheduledOccurrenceVersions,
|
||||
}
|
||||
|
||||
let persisted = PersistedOccurrence::deserialize(deserializer)?;
|
||||
if persisted.task.recurring || !persisted.task.durable {
|
||||
return Err(serde::de::Error::custom(
|
||||
OccurrenceJournalError::NotDurableOneShot(persisted.task.id),
|
||||
));
|
||||
}
|
||||
Ok(Self {
|
||||
occurrence_id: persisted.occurrence_id,
|
||||
task: persisted.task,
|
||||
versions: persisted.versions,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct OccurrenceJournal {
|
||||
entries: Vec<OneShotOccurrence>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
quarantined_task_ids: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
block_all_one_shots: bool,
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
overflowed: bool,
|
||||
}
|
||||
|
||||
impl OccurrenceJournal {
|
||||
pub(super) fn is_empty(&self) -> bool {
|
||||
self.entries.is_empty()
|
||||
&& self.quarantined_task_ids.is_empty()
|
||||
&& !self.block_all_one_shots
|
||||
&& !self.overflowed
|
||||
}
|
||||
|
||||
#[cfg_attr(
|
||||
not(test),
|
||||
expect(dead_code, reason = "wired by durable one-shot actor layer")
|
||||
)]
|
||||
pub(super) fn quarantine_diagnostics(&self) -> (&[String], bool, bool) {
|
||||
(
|
||||
&self.quarantined_task_ids,
|
||||
self.block_all_one_shots,
|
||||
self.overflowed,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// JSON-only because Resources persistence stores this state as `serde_json::Value`.
|
||||
impl<'de> Deserialize<'de> for OccurrenceJournal {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let value = serde_json::Value::deserialize(deserializer)?;
|
||||
Ok(Self::decode_json(value))
|
||||
}
|
||||
}
|
||||
|
||||
impl OccurrenceJournal {
|
||||
fn decode_json(value: serde_json::Value) -> Self {
|
||||
let (entries, task_ids, block_all, overflowed, is_malformed) = match value {
|
||||
serde_json::Value::Array(entries) => (entries, Vec::new(), false, false, false),
|
||||
serde_json::Value::Object(mut object) => {
|
||||
let (entries, bad_entries) = parse_json_array(object.remove("entries"));
|
||||
let (task_values, bad_task_ids) =
|
||||
parse_json_array(object.remove("quarantinedTaskIds"));
|
||||
let bad_task_element = task_values.iter().any(|value| !value.is_string());
|
||||
let task_ids: Vec<String> = task_values
|
||||
.into_iter()
|
||||
.filter_map(|value| value.as_str().map(str::to_owned))
|
||||
.collect();
|
||||
let (block_all, bad_block) = parse_json_bool(object.remove("blockAllOneShots"));
|
||||
let (overflowed, bad_overflow) = parse_json_bool(object.remove("overflowed"));
|
||||
(
|
||||
entries,
|
||||
task_ids,
|
||||
block_all,
|
||||
overflowed,
|
||||
bad_entries || bad_task_ids || bad_task_element || bad_block || bad_overflow,
|
||||
)
|
||||
}
|
||||
_ => (Vec::new(), Vec::new(), true, false, true),
|
||||
};
|
||||
let mut journal = Self {
|
||||
block_all_one_shots: block_all || overflowed || is_malformed,
|
||||
overflowed,
|
||||
..Self::default()
|
||||
};
|
||||
for task_id in task_ids {
|
||||
journal.quarantine_task_id(task_id);
|
||||
}
|
||||
if entries.len() > MAX_PENDING_ONE_SHOTS {
|
||||
journal.block_all_one_shots = true;
|
||||
journal.overflowed = true;
|
||||
}
|
||||
for value in entries.into_iter().take(MAX_PENDING_ONE_SHOTS) {
|
||||
match serde_json::from_value(value.clone()) {
|
||||
Ok(occurrence) => journal.entries.push(occurrence),
|
||||
Err(_) => match quarantined_task_id(&value) {
|
||||
Some(task_id) => journal.quarantine_task_id(task_id),
|
||||
None => journal.block_all_one_shots = true,
|
||||
},
|
||||
}
|
||||
}
|
||||
journal
|
||||
}
|
||||
|
||||
fn quarantine_task_id(&mut self, task_id: String) {
|
||||
if task_id.is_empty() || task_id.len() > MAX_TASK_ID_BYTES {
|
||||
self.block_all_one_shots = true;
|
||||
} else if !self.quarantined_task_ids.contains(&task_id) {
|
||||
if self.quarantined_task_ids.len() == MAX_QUARANTINED_TASK_IDS {
|
||||
self.block_all_one_shots = true;
|
||||
} else {
|
||||
self.quarantined_task_ids.push(task_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_json_array(value: Option<serde_json::Value>) -> (Vec<serde_json::Value>, bool) {
|
||||
value.map_or((Vec::new(), false), |value| match value {
|
||||
serde_json::Value::Array(values) => (values, false),
|
||||
_ => (Vec::new(), true),
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_json_bool(value: Option<serde_json::Value>) -> (bool, bool) {
|
||||
value.map_or((false, false), |value| match value {
|
||||
serde_json::Value::Bool(value) => (value, false),
|
||||
_ => (true, true),
|
||||
})
|
||||
}
|
||||
|
||||
fn quarantined_task_id(value: &serde_json::Value) -> Option<String> {
|
||||
value.get("task")?.get("id")?.as_str().map(str::to_owned)
|
||||
}
|
||||
|
||||
#[derive(thiserror::Error, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum OccurrenceJournalError {
|
||||
#[error("scheduled task {0} was not found")]
|
||||
TaskNotFound(String),
|
||||
|
||||
#[error("scheduled task {0} is not a durable one-shot")]
|
||||
NotDurableOneShot(String),
|
||||
|
||||
#[error("scheduled task {0} already has a pending occurrence")]
|
||||
TaskAlreadyJournaled(String),
|
||||
|
||||
#[error("maximum of {MAX_PENDING_ONE_SHOTS} pending one-shot occurrences reached")]
|
||||
JournalFull,
|
||||
|
||||
#[error("one-shot fire/removal versions must be nonzero consecutive RFC UUIDv7 transitions")]
|
||||
InvalidVersions,
|
||||
|
||||
#[error("scheduler transition version is already journaled")]
|
||||
DuplicateTransitionVersion,
|
||||
|
||||
#[error("one-shot journal requires manual recovery before new occurrences can be prepared")]
|
||||
RecoveryRequired,
|
||||
|
||||
#[error("scheduled occurrence was not found")]
|
||||
OccurrenceNotFound,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum OneShotJournalConflict {
|
||||
OccurrenceId,
|
||||
TaskId,
|
||||
TransitionVersion,
|
||||
}
|
||||
|
||||
#[must_use = "loaded one-shot receipts must suppress timers and reconcile resources"]
|
||||
pub(crate) struct SchedulerLoadReconciliation {
|
||||
requires_resources_persistence: bool,
|
||||
task_ids_to_remove: Vec<String>,
|
||||
blocked_task_ids: HashSet<String>,
|
||||
block_all_one_shots: bool,
|
||||
recovery_required: bool,
|
||||
conflicts: Vec<OneShotJournalConflict>,
|
||||
overflow_error: Option<OccurrenceJournalError>,
|
||||
}
|
||||
|
||||
impl SchedulerLoadReconciliation {
|
||||
#[cfg_attr(
|
||||
not(test),
|
||||
expect(dead_code, reason = "wired by durable one-shot actor layer")
|
||||
)]
|
||||
pub(super) fn requires_resources_persistence(&self) -> bool {
|
||||
self.requires_resources_persistence
|
||||
}
|
||||
|
||||
#[cfg_attr(
|
||||
not(test),
|
||||
expect(dead_code, reason = "wired by durable one-shot actor layer")
|
||||
)]
|
||||
pub(super) fn task_ids_to_remove(&self) -> &[String] {
|
||||
&self.task_ids_to_remove
|
||||
}
|
||||
|
||||
#[cfg_attr(
|
||||
not(test),
|
||||
expect(dead_code, reason = "wired by durable one-shot actor layer")
|
||||
)]
|
||||
pub(super) fn blocked_task_ids(&self) -> &HashSet<String> {
|
||||
&self.blocked_task_ids
|
||||
}
|
||||
|
||||
#[cfg_attr(
|
||||
not(test),
|
||||
expect(dead_code, reason = "wired by durable one-shot actor layer")
|
||||
)]
|
||||
pub(super) fn block_all_one_shots(&self) -> bool {
|
||||
self.block_all_one_shots
|
||||
}
|
||||
|
||||
#[cfg_attr(
|
||||
not(test),
|
||||
expect(dead_code, reason = "wired by durable one-shot actor layer")
|
||||
)]
|
||||
pub(super) fn recovery_required(&self) -> bool {
|
||||
self.recovery_required
|
||||
}
|
||||
|
||||
#[cfg_attr(
|
||||
not(test),
|
||||
expect(dead_code, reason = "wired by durable one-shot actor layer")
|
||||
)]
|
||||
pub(super) fn conflicts(&self) -> &[OneShotJournalConflict] {
|
||||
&self.conflicts
|
||||
}
|
||||
|
||||
#[cfg_attr(
|
||||
not(test),
|
||||
expect(dead_code, reason = "wired by durable one-shot actor layer")
|
||||
)]
|
||||
pub(super) fn overflow_error(&self) -> Option<&OccurrenceJournalError> {
|
||||
self.overflow_error.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
impl SchedulerState {
|
||||
#[cfg_attr(
|
||||
not(test),
|
||||
expect(dead_code, reason = "wired by durable one-shot actor layer")
|
||||
)]
|
||||
pub(super) fn prepare_one_shot_occurrence(
|
||||
&mut self,
|
||||
task_id: &str,
|
||||
versions: ScheduledOccurrenceVersions,
|
||||
) -> Result<OneShotOccurrence, OccurrenceJournalError> {
|
||||
self.prepare_one_shot_occurrence_with_id(ScheduledOccurrenceId::new(), task_id, versions)
|
||||
}
|
||||
|
||||
fn prepare_one_shot_occurrence_with_id(
|
||||
&mut self,
|
||||
occurrence_id: ScheduledOccurrenceId,
|
||||
task_id: &str,
|
||||
versions: ScheduledOccurrenceVersions,
|
||||
) -> Result<OneShotOccurrence, OccurrenceJournalError> {
|
||||
if !self.occurrence_journal.quarantined_task_ids.is_empty()
|
||||
|| self.occurrence_journal.block_all_one_shots
|
||||
|| self.occurrence_journal.overflowed
|
||||
|| has_conflict(&self.occurrence_journal.entries)
|
||||
{
|
||||
return Err(OccurrenceJournalError::RecoveryRequired);
|
||||
}
|
||||
if self.occurrence_journal.entries.len() >= MAX_PENDING_ONE_SHOTS {
|
||||
return Err(OccurrenceJournalError::JournalFull);
|
||||
}
|
||||
if self
|
||||
.occurrence_journal
|
||||
.entries
|
||||
.iter()
|
||||
.any(|occurrence| occurrence.task.id == task_id)
|
||||
{
|
||||
return Err(OccurrenceJournalError::TaskAlreadyJournaled(
|
||||
task_id.to_owned(),
|
||||
));
|
||||
}
|
||||
if self.occurrence_journal.entries.iter().any(|occurrence| {
|
||||
occurrence.versions.contains(versions.fire())
|
||||
|| occurrence.versions.contains(versions.removal())
|
||||
}) {
|
||||
return Err(OccurrenceJournalError::DuplicateTransitionVersion);
|
||||
}
|
||||
let index = self
|
||||
.tasks
|
||||
.iter()
|
||||
.position(|task| task.id == task_id)
|
||||
.ok_or_else(|| OccurrenceJournalError::TaskNotFound(task_id.to_owned()))?;
|
||||
if self.tasks[index].recurring || !self.tasks[index].durable {
|
||||
return Err(OccurrenceJournalError::NotDurableOneShot(
|
||||
task_id.to_owned(),
|
||||
));
|
||||
}
|
||||
|
||||
let occurrence = OneShotOccurrence {
|
||||
occurrence_id,
|
||||
task: self.tasks.remove(index),
|
||||
versions,
|
||||
};
|
||||
self.occurrence_journal.entries.push(occurrence.clone());
|
||||
Ok(occurrence)
|
||||
}
|
||||
|
||||
#[must_use = "the exact removal receipt must be durably cleared"]
|
||||
#[cfg_attr(
|
||||
not(test),
|
||||
expect(dead_code, reason = "wired by durable one-shot actor layer")
|
||||
)]
|
||||
pub(super) fn finish_one_shot_removal(
|
||||
&mut self,
|
||||
occurrence_id: &ScheduledOccurrenceId,
|
||||
) -> Result<OneShotOccurrence, OccurrenceJournalError> {
|
||||
let index = self
|
||||
.occurrence_journal
|
||||
.entries
|
||||
.iter()
|
||||
.position(|occurrence| occurrence.occurrence_id == *occurrence_id)
|
||||
.ok_or(OccurrenceJournalError::OccurrenceNotFound)?;
|
||||
Ok(self.occurrence_journal.entries.remove(index))
|
||||
}
|
||||
|
||||
#[cfg_attr(
|
||||
not(test),
|
||||
expect(dead_code, reason = "wired by durable one-shot actor layer")
|
||||
)]
|
||||
pub(super) fn reconcile_one_shot_occurrences(&self) -> SchedulerLoadReconciliation {
|
||||
let occurrence_counts = count_by(self.occurrence_journal.entries.iter(), |entry| {
|
||||
entry.occurrence_id.clone()
|
||||
});
|
||||
let task_counts = count_by(self.occurrence_journal.entries.iter(), |entry| {
|
||||
entry.task.id.clone()
|
||||
});
|
||||
let mut version_counts = HashMap::new();
|
||||
for occurrence in &self.occurrence_journal.entries {
|
||||
for version in [occurrence.versions.fire(), occurrence.versions.removal()] {
|
||||
*version_counts.entry(version).or_insert(0usize) += 1;
|
||||
}
|
||||
}
|
||||
let conflict_for = |occurrence: &OneShotOccurrence| {
|
||||
if occurrence_counts[&occurrence.occurrence_id] > 1 {
|
||||
Some(OneShotJournalConflict::OccurrenceId)
|
||||
} else if task_counts[&occurrence.task.id] > 1 {
|
||||
Some(OneShotJournalConflict::TaskId)
|
||||
} else if version_counts[&occurrence.versions.fire()] > 1
|
||||
|| version_counts[&occurrence.versions.removal()] > 1
|
||||
{
|
||||
Some(OneShotJournalConflict::TransitionVersion)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
let mut blocked_task_ids: HashSet<String> = self
|
||||
.occurrence_journal
|
||||
.quarantined_task_ids
|
||||
.iter()
|
||||
.cloned()
|
||||
.collect();
|
||||
let block_all_one_shots = self.occurrence_journal.block_all_one_shots;
|
||||
let overflowed = self.occurrence_journal.overflowed;
|
||||
let conflicts: Vec<_> = self
|
||||
.occurrence_journal
|
||||
.entries
|
||||
.iter()
|
||||
.filter_map(conflict_for)
|
||||
.collect();
|
||||
let recovery_required = block_all_one_shots
|
||||
|| overflowed
|
||||
|| !self.occurrence_journal.quarantined_task_ids.is_empty()
|
||||
|| !conflicts.is_empty();
|
||||
blocked_task_ids.extend(
|
||||
self.occurrence_journal
|
||||
.entries
|
||||
.iter()
|
||||
.map(|occurrence| occurrence.task.id.clone()),
|
||||
);
|
||||
if block_all_one_shots {
|
||||
blocked_task_ids.extend(
|
||||
self.tasks
|
||||
.iter()
|
||||
.filter(|task| !task.recurring)
|
||||
.map(|task| task.id.clone()),
|
||||
);
|
||||
}
|
||||
|
||||
let task_ids_to_remove: Vec<String> = if recovery_required {
|
||||
Vec::new()
|
||||
} else {
|
||||
let journaled: HashSet<&str> = self
|
||||
.occurrence_journal
|
||||
.entries
|
||||
.iter()
|
||||
.map(|occurrence| occurrence.task.id.as_str())
|
||||
.collect();
|
||||
self.tasks
|
||||
.iter()
|
||||
.filter(|task| journaled.contains(task.id.as_str()))
|
||||
.map(|task| task.id.clone())
|
||||
.collect()
|
||||
};
|
||||
|
||||
SchedulerLoadReconciliation {
|
||||
requires_resources_persistence: !task_ids_to_remove.is_empty(),
|
||||
task_ids_to_remove,
|
||||
blocked_task_ids,
|
||||
block_all_one_shots,
|
||||
recovery_required,
|
||||
conflicts,
|
||||
overflow_error: overflowed.then_some(OccurrenceJournalError::JournalFull),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn has_conflict(entries: &[OneShotOccurrence]) -> bool {
|
||||
let occurrence_ids: HashSet<_> = entries.iter().map(|entry| &entry.occurrence_id).collect();
|
||||
let task_ids: HashSet<_> = entries.iter().map(|entry| entry.task.id.as_str()).collect();
|
||||
let versions: HashSet<_> = entries
|
||||
.iter()
|
||||
.flat_map(|entry| [entry.versions.fire(), entry.versions.removal()])
|
||||
.collect();
|
||||
occurrence_ids.len() != entries.len()
|
||||
|| task_ids.len() != entries.len()
|
||||
|| versions.len() != entries.len() * 2
|
||||
}
|
||||
|
||||
fn count_by<'a, T, K>(
|
||||
values: impl Iterator<Item = &'a T>,
|
||||
key: impl Fn(&T) -> K,
|
||||
) -> HashMap<K, usize>
|
||||
where
|
||||
T: 'a,
|
||||
K: Eq + std::hash::Hash,
|
||||
{
|
||||
let mut counts = HashMap::new();
|
||||
for value in values {
|
||||
*counts.entry(key(value)).or_insert(0) += 1;
|
||||
}
|
||||
counts
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "occurrence_journal_tests.rs"]
|
||||
mod tests;
|
||||
|
|
@ -0,0 +1,391 @@
|
|||
use super::*;
|
||||
use crate::persistence::ResourcesPersistence;
|
||||
use crate::types::resources::{Resources, State};
|
||||
use chrono::{TimeZone, Utc};
|
||||
|
||||
const GENERATION: &str = "01890f42-7d5c-7c00-8000-000000000001";
|
||||
|
||||
fn uuid(suffix: u64) -> uuid::Uuid {
|
||||
uuid::Uuid::parse_str(&format!("01890f42-7d5c-7c00-8000-{suffix:012x}")).unwrap()
|
||||
}
|
||||
|
||||
fn task(id: &str, recurring: bool, durable: bool) -> ScheduledTask {
|
||||
ScheduledTask {
|
||||
id: id.into(),
|
||||
interval_secs: 300,
|
||||
prompt: format!("run {id}"),
|
||||
recurring,
|
||||
durable,
|
||||
foreground: true,
|
||||
created_at: Utc.timestamp_opt(1_700_000_000, 0).unwrap(),
|
||||
last_fired_at: None,
|
||||
expires_at: None,
|
||||
last_subagent_id: None,
|
||||
iterations_since_fresh: 0,
|
||||
chain_reset_pending: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn version(generation: &str, revision: u64) -> SchedulerVersion {
|
||||
SchedulerVersion::from_parts(uuid::Uuid::parse_str(generation).unwrap(), revision)
|
||||
}
|
||||
|
||||
fn versions(revision: u64) -> ScheduledOccurrenceVersions {
|
||||
ScheduledOccurrenceVersions::try_new(
|
||||
version(GENERATION, revision),
|
||||
version(GENERATION, revision + 1),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn occurrence_json(
|
||||
id: &str,
|
||||
task: serde_json::Value,
|
||||
versions: serde_json::Value,
|
||||
) -> serde_json::Value {
|
||||
serde_json::json!({ "occurrenceId": id, "task": task, "versions": versions })
|
||||
}
|
||||
|
||||
fn valid_occurrence_json(id_suffix: u64, task_id: &str, revision: u64) -> serde_json::Value {
|
||||
occurrence_json(
|
||||
&uuid(id_suffix).to_string(),
|
||||
serde_json::to_value(task(task_id, false, true)).unwrap(),
|
||||
serde_json::json!({
|
||||
"fire": { "generation": GENERATION, "revision": revision },
|
||||
"removal": { "generation": GENERATION, "revision": revision + 1 },
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn state(tasks: Vec<ScheduledTask>, journal: serde_json::Value) -> SchedulerState {
|
||||
serde_json::from_value(serde_json::json!({
|
||||
"tasks": tasks,
|
||||
"occurrenceJournal": journal
|
||||
}))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn prepare(state: &mut SchedulerState, task_id: &str, revision: u64) -> OneShotOccurrence {
|
||||
state
|
||||
.prepare_one_shot_occurrence_with_id(
|
||||
ScheduledOccurrenceId(uuid(100 + revision)),
|
||||
task_id,
|
||||
versions(revision),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepare_finish_and_mutation_failures_preserve_state() {
|
||||
let mut state = SchedulerState {
|
||||
tasks: vec![task("one-shot", false, true), task("second", false, true)],
|
||||
..Default::default()
|
||||
};
|
||||
let occurrence = prepare(&mut state, "one-shot", 7);
|
||||
assert_eq!(occurrence.task.id, "one-shot");
|
||||
state
|
||||
.finish_one_shot_removal(&occurrence.occurrence_id)
|
||||
.unwrap();
|
||||
|
||||
prepare(&mut state, "second", 1);
|
||||
state.tasks.push(task("duplicate", false, true));
|
||||
assert_eq!(
|
||||
state
|
||||
.prepare_one_shot_occurrence("duplicate", versions(1))
|
||||
.unwrap_err(),
|
||||
OccurrenceJournalError::DuplicateTransitionVersion
|
||||
);
|
||||
|
||||
for invalid in [
|
||||
task("recurring", true, true),
|
||||
task("ephemeral", false, false),
|
||||
] {
|
||||
let mut state = SchedulerState {
|
||||
tasks: vec![invalid.clone()],
|
||||
..Default::default()
|
||||
};
|
||||
assert!(matches!(
|
||||
state.prepare_one_shot_occurrence(&invalid.id, versions(3)),
|
||||
Err(OccurrenceJournalError::NotDurableOneShot(_))
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validation_rejects_impossible_versions_and_non_rfc_identity() {
|
||||
for (fire_generation, removal_generation, fire, removal) in [
|
||||
(GENERATION, GENERATION, 0, 1),
|
||||
(GENERATION, GENERATION, 1, 3),
|
||||
(GENERATION, "01890f42-7d5c-7c00-8000-000000000002", 1, 2),
|
||||
("01890f42-7d5c-7c00-c000-000000000001", GENERATION, 1, 2),
|
||||
] {
|
||||
assert_eq!(
|
||||
ScheduledOccurrenceVersions::try_new(
|
||||
version(fire_generation, fire),
|
||||
version(removal_generation, removal),
|
||||
),
|
||||
Err(OccurrenceJournalError::InvalidVersions)
|
||||
);
|
||||
}
|
||||
|
||||
let invalid = occurrence_json(
|
||||
"01890f42-7d5c-7c00-c000-000000000001",
|
||||
serde_json::to_value(task("bad-id", false, true)).unwrap(),
|
||||
serde_json::json!({
|
||||
"fire": { "generation": GENERATION, "revision": 1 },
|
||||
"removal": { "generation": GENERATION, "revision": 2 },
|
||||
}),
|
||||
);
|
||||
let state = state(Vec::new(), serde_json::Value::Array(vec![invalid]));
|
||||
let plan = state.reconcile_one_shot_occurrences();
|
||||
assert!(plan.recovery_required() && plan.blocked_task_ids().contains("bad-id"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exactly_fifty_round_trips_and_mutation_reports_journal_full() {
|
||||
let entries: Vec<_> = (0..MAX_PENDING_ONE_SHOTS)
|
||||
.map(|index| {
|
||||
valid_occurrence_json(
|
||||
100 + index as u64,
|
||||
&format!("task-{index}"),
|
||||
index as u64 * 2 + 1,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let mut state = state(Vec::new(), serde_json::Value::Array(entries));
|
||||
assert_eq!(
|
||||
state.occurrence_journal.entries.len(),
|
||||
MAX_PENDING_ONE_SHOTS
|
||||
);
|
||||
let encoded = serde_json::to_value(&state).unwrap();
|
||||
let reloaded: SchedulerState = serde_json::from_value(encoded).unwrap();
|
||||
assert_eq!(
|
||||
reloaded.occurrence_journal.entries.len(),
|
||||
MAX_PENDING_ONE_SHOTS
|
||||
);
|
||||
|
||||
state.tasks.push(task("new", false, true));
|
||||
assert_eq!(
|
||||
state
|
||||
.prepare_one_shot_occurrence("new", versions(3))
|
||||
.unwrap_err(),
|
||||
OccurrenceJournalError::JournalFull
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overflow_tail_suppresses_globally_and_never_serializes_a_fifty_first_entry() {
|
||||
let mut entries: Vec<_> = (0..MAX_PENDING_ONE_SHOTS)
|
||||
.map(|index| valid_occurrence_json(200 + index as u64, &format!("task-{index}"), 1))
|
||||
.collect();
|
||||
entries.push(valid_occurrence_json(999, "tail-task", 3));
|
||||
let state = state(
|
||||
vec![task("tail-task", false, true), task("other", false, true)],
|
||||
serde_json::Value::Array(entries),
|
||||
);
|
||||
|
||||
let plan = state.reconcile_one_shot_occurrences();
|
||||
assert!(plan.block_all_one_shots() && plan.recovery_required());
|
||||
assert!(!plan.requires_resources_persistence());
|
||||
assert!(plan.blocked_task_ids().contains("tail-task"));
|
||||
assert!(plan.overflow_error().is_some());
|
||||
|
||||
let encoded = serde_json::to_value(&state).unwrap();
|
||||
assert_eq!(
|
||||
encoded["occurrenceJournal"]["entries"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.len(),
|
||||
MAX_PENDING_ONE_SHOTS
|
||||
);
|
||||
let mut reloaded: SchedulerState = serde_json::from_value(encoded).unwrap();
|
||||
let reloaded_plan = reloaded.reconcile_one_shot_occurrences();
|
||||
assert!(reloaded_plan.block_all_one_shots() && reloaded_plan.recovery_required());
|
||||
reloaded.tasks.push(task("new", false, true));
|
||||
let before = reloaded.tasks.len();
|
||||
assert_eq!(
|
||||
reloaded
|
||||
.prepare_one_shot_occurrence("new", versions(5))
|
||||
.unwrap_err(),
|
||||
OccurrenceJournalError::RecoveryRequired
|
||||
);
|
||||
assert_eq!(reloaded.tasks.len(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_missing_task_identity_blocks_all_one_shots_across_reload() {
|
||||
let malformed = occurrence_json(
|
||||
&uuid(20).to_string(),
|
||||
serde_json::json!({ "prompt": "missing id" }),
|
||||
serde_json::json!({
|
||||
"fire": { "generation": GENERATION, "revision": 1 },
|
||||
"removal": { "generation": GENERATION, "revision": 2 },
|
||||
}),
|
||||
);
|
||||
let state = state(
|
||||
vec![task("due", false, true), task("recurring", true, true)],
|
||||
serde_json::Value::Array(vec![malformed]),
|
||||
);
|
||||
let plan = state.reconcile_one_shot_occurrences();
|
||||
assert!(plan.block_all_one_shots() && plan.recovery_required());
|
||||
assert!(plan.blocked_task_ids().contains("due"));
|
||||
|
||||
let encoded = serde_json::to_value(&state).unwrap();
|
||||
let reloaded: SchedulerState = serde_json::from_value(encoded).unwrap();
|
||||
let reloaded_plan = reloaded.reconcile_one_shot_occurrences();
|
||||
assert!(reloaded_plan.block_all_one_shots() && reloaded_plan.recovery_required());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inconsistent_current_overflow_metadata_normalizes_and_round_trips() {
|
||||
let current = serde_json::json!({
|
||||
"entries": [],
|
||||
"overflowed": true,
|
||||
"blockAllOneShots": false,
|
||||
});
|
||||
let state = state(vec![task("due", false, true)], current);
|
||||
let plan = state.reconcile_one_shot_occurrences();
|
||||
assert!(plan.block_all_one_shots() && plan.recovery_required());
|
||||
|
||||
let encoded = serde_json::to_value(&state).unwrap();
|
||||
assert!(encoded["occurrenceJournal"]["blockAllOneShots"] == true);
|
||||
let reloaded: SchedulerState = serde_json::from_value(encoded).unwrap();
|
||||
assert!(
|
||||
reloaded
|
||||
.reconcile_one_shot_occurrences()
|
||||
.recovery_required()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn production_loader_preserves_tasks_and_quarantine_metadata() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("resources_state.json");
|
||||
let invalid = occurrence_json(
|
||||
&uuid(30).to_string(),
|
||||
serde_json::to_value(task("bad", true, true)).unwrap(),
|
||||
serde_json::json!({
|
||||
"fire": { "generation": GENERATION, "revision": 1 },
|
||||
"removal": { "generation": GENERATION, "revision": 2 },
|
||||
}),
|
||||
);
|
||||
std::fs::write(
|
||||
&path,
|
||||
serde_json::to_vec(&serde_json::json!({
|
||||
"state": { "grok_build.Scheduler": {
|
||||
"tasks": [task("recurring", true, true)],
|
||||
"occurrenceJournal": [invalid]
|
||||
} }
|
||||
}))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mut resources = Resources::new();
|
||||
resources.register_state::<SchedulerState>();
|
||||
assert!(ResourcesPersistence::new(path.clone()).load(&mut resources));
|
||||
let state = resources.get::<State<SchedulerState>>().unwrap();
|
||||
assert_eq!(state.tasks[0].id, "recurring");
|
||||
let (task_ids, is_global_block, is_overflowed) =
|
||||
state.occurrence_journal.quarantine_diagnostics();
|
||||
assert_eq!(task_ids, ["bad"]);
|
||||
assert!(!is_global_block && !is_overflowed);
|
||||
|
||||
for journal in [
|
||||
serde_json::json!({ "entries": "bad", "blockAllOneShots": [] }),
|
||||
serde_json::json!({ "quarantinedTaskIds": ["kept-id", 7] }),
|
||||
serde_json::json!("wrong-shape"),
|
||||
] {
|
||||
std::fs::write(
|
||||
&path,
|
||||
serde_json::to_vec(&serde_json::json!({
|
||||
"state": { "grok_build.Scheduler": {
|
||||
"tasks": [task("kept", true, true)],
|
||||
"occurrenceJournal": journal
|
||||
} }
|
||||
}))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut resources = Resources::new();
|
||||
resources.register_state::<SchedulerState>();
|
||||
assert!(ResourcesPersistence::new(path.clone()).load(&mut resources));
|
||||
let state = resources.get::<State<SchedulerState>>().unwrap();
|
||||
assert_eq!(state.tasks[0].id, "kept");
|
||||
assert!(state.occurrence_journal.block_all_one_shots);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconciliation_exposes_only_persistence_and_suppression_foundation() {
|
||||
let state = state(
|
||||
vec![task("resurrected", false, true)],
|
||||
serde_json::Value::Array(vec![valid_occurrence_json(10, "resurrected", 1)]),
|
||||
);
|
||||
let plan = state.reconcile_one_shot_occurrences();
|
||||
assert!(plan.requires_resources_persistence());
|
||||
assert_eq!(plan.task_ids_to_remove(), ["resurrected"]);
|
||||
assert_eq!(state.tasks[0].id, "resurrected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conflict_receipts_produce_diagnostics_and_suppress_every_task() {
|
||||
for (entries, expected) in [
|
||||
(
|
||||
vec![
|
||||
valid_occurrence_json(10, "first", 1),
|
||||
valid_occurrence_json(10, "second", 3),
|
||||
],
|
||||
OneShotJournalConflict::OccurrenceId,
|
||||
),
|
||||
(
|
||||
vec![
|
||||
valid_occurrence_json(10, "same", 1),
|
||||
valid_occurrence_json(11, "same", 3),
|
||||
],
|
||||
OneShotJournalConflict::TaskId,
|
||||
),
|
||||
(
|
||||
vec![
|
||||
valid_occurrence_json(10, "first", 1),
|
||||
valid_occurrence_json(11, "second", 1),
|
||||
],
|
||||
OneShotJournalConflict::TransitionVersion,
|
||||
),
|
||||
] {
|
||||
let ids: Vec<_> = entries
|
||||
.iter()
|
||||
.map(|entry| entry["task"]["id"].as_str().unwrap().to_owned())
|
||||
.collect();
|
||||
let mut state = state(
|
||||
ids.iter().map(|id| task(id, false, true)).collect(),
|
||||
serde_json::Value::Array(entries),
|
||||
);
|
||||
let plan = state.reconcile_one_shot_occurrences();
|
||||
assert!(plan.recovery_required());
|
||||
assert!(plan.task_ids_to_remove().is_empty());
|
||||
assert_eq!(plan.conflicts(), &[expected, expected]);
|
||||
assert!(ids.iter().all(|id| plan.blocked_task_ids().contains(id)));
|
||||
assert_eq!(state.tasks.len(), ids.len());
|
||||
let unrelated = "unrelated";
|
||||
state.tasks.push(task(unrelated, false, true));
|
||||
let before = state.tasks.len();
|
||||
assert_eq!(
|
||||
state
|
||||
.prepare_one_shot_occurrence(unrelated, versions(9))
|
||||
.unwrap_err(),
|
||||
OccurrenceJournalError::RecoveryRequired
|
||||
);
|
||||
assert_eq!(state.tasks.len(), before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_journal_omits_legacy_field() {
|
||||
let serialized = serde_json::to_value(SchedulerState {
|
||||
tasks: vec![task("legacy", true, true)],
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap();
|
||||
assert!(serialized.get("occurrenceJournal").is_none());
|
||||
}
|
||||
|
|
@ -2,7 +2,8 @@ use chrono::{DateTime, Utc};
|
|||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::{mpsc, oneshot};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct SchedulerVersion {
|
||||
generation: uuid::Uuid,
|
||||
revision: u64,
|
||||
|
|
@ -16,6 +17,18 @@ impl SchedulerVersion {
|
|||
pub(super) fn revision(self) -> u64 {
|
||||
self.revision
|
||||
}
|
||||
|
||||
pub(super) fn generation_id(self) -> uuid::Uuid {
|
||||
self.generation
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn from_parts(generation: uuid::Uuid, revision: u64) -> Self {
|
||||
Self {
|
||||
generation,
|
||||
revision,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
|
|
@ -279,7 +292,14 @@ impl ScheduledTask {
|
|||
/// Persisted state for the scheduler, stored via Resources + ResourcesPersistence.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct SchedulerState {
|
||||
#[serde(default)]
|
||||
pub tasks: Vec<ScheduledTask>,
|
||||
#[serde(
|
||||
default,
|
||||
rename = "occurrenceJournal",
|
||||
skip_serializing_if = "super::occurrence_journal::OccurrenceJournal::is_empty"
|
||||
)]
|
||||
pub(crate) occurrence_journal: super::occurrence_journal::OccurrenceJournal,
|
||||
}
|
||||
|
||||
crate::register_resource!("grok_build", "Scheduler", SchedulerState);
|
||||
|
|
|
|||
|
|
@ -767,6 +767,8 @@ impl crate::types::tool_metadata::ToolMetadata for SearchReplaceTool {
|
|||
}
|
||||
fn requires_expr(&self) -> Expr<ToolRequirement> {
|
||||
Expr::And(vec![
|
||||
// Unless `skip_read_before_edit` is set, require a Read tool in the toolset
|
||||
// (read-before-edit is encouraged via description and RL grading, not runtime-enforced).
|
||||
Expr::Value(ToolRequirement::if_params(
|
||||
Expr::Not(Box::new(Expr::Value(ToolParamsRequirement::new(
|
||||
"skip_read_before_edit",
|
||||
|
|
@ -774,6 +776,12 @@ impl crate::types::tool_metadata::ToolMetadata for SearchReplaceTool {
|
|||
)))),
|
||||
ToolRequirement::tool_kind(ToolKind::Read),
|
||||
)),
|
||||
// Description template references these input params via
|
||||
// ${{ params.edit.old_string }}, ${{ params.edit.new_string }},
|
||||
// ${{ params.edit.replace_all }}. They must remain visible.
|
||||
// TODO: We can generate the schemas and requirement by enforcing
|
||||
// it during the registry phase, since these are parts of the params which are
|
||||
// tied to the tool
|
||||
Expr::Value(ToolRequirement::input_param(ToolKind::Edit, "old_string")),
|
||||
Expr::Value(ToolRequirement::input_param(ToolKind::Edit, "new_string")),
|
||||
Expr::Value(ToolRequirement::input_param(ToolKind::Edit, "replace_all")),
|
||||
|
|
@ -903,7 +911,7 @@ mod tests {
|
|||
/// Harness configs still send this field; it must keep validating under `deny_unknown_fields`.
|
||||
#[test]
|
||||
fn harness_skip_read_before_edit_param_still_validates() {
|
||||
let json = serde_json::json!({ "skip_read_before_edit" : true });
|
||||
let json = serde_json::json!({ "skip_read_before_edit": true });
|
||||
crate::types::params_validation::validate_params_json::<SearchReplaceParams>(&json).expect(
|
||||
"harness skip_read_before_edit config must validate against SearchReplaceParams",
|
||||
);
|
||||
|
|
|
|||
|
|
@ -527,6 +527,7 @@ pub enum SubagentCancelOutcome {
|
|||
#[derive(Debug, Clone)]
|
||||
pub struct SubagentCompletionSummary {
|
||||
pub subagent_id: String,
|
||||
pub owner_session_id: String,
|
||||
pub subagent_type: String,
|
||||
pub description: String,
|
||||
pub success: bool,
|
||||
|
|
@ -559,6 +560,7 @@ pub struct SubagentMultiWaitRequest {
|
|||
#[derive(Educe)]
|
||||
#[educe(Debug)]
|
||||
pub struct SubagentCompletionsRequest {
|
||||
pub session_id: String,
|
||||
pub suppress_ids: Vec<String>,
|
||||
#[educe(Debug(ignore))]
|
||||
pub respond_to: oneshot::Sender<Vec<SubagentCompletionSummary>>,
|
||||
|
|
@ -1280,16 +1282,19 @@ mod tests {
|
|||
let (respond_to, mut response_rx) = oneshot::channel();
|
||||
|
||||
tx.send(super::SubagentCompletionsRequest {
|
||||
session_id: "session-1".into(),
|
||||
suppress_ids: vec!["id-1".into(), "id-2".into()],
|
||||
respond_to,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let req = rx.try_recv().unwrap();
|
||||
assert_eq!(req.session_id, "session-1");
|
||||
assert_eq!(req.suppress_ids, vec!["id-1", "id-2"]);
|
||||
|
||||
let summaries = vec![super::SubagentCompletionSummary {
|
||||
subagent_id: "sub-1".into(),
|
||||
owner_session_id: "session-1".into(),
|
||||
subagent_type: "general-purpose".into(),
|
||||
description: "test task".into(),
|
||||
success: true,
|
||||
|
|
@ -1368,6 +1373,7 @@ mod tests {
|
|||
.0
|
||||
.send(super::SubagentEvent::Completions(
|
||||
super::SubagentCompletionsRequest {
|
||||
session_id: String::new(),
|
||||
suppress_ids: vec![],
|
||||
respond_to,
|
||||
},
|
||||
|
|
@ -1399,6 +1405,7 @@ mod tests {
|
|||
.0
|
||||
.send(super::SubagentEvent::Completions(
|
||||
super::SubagentCompletionsRequest {
|
||||
session_id: String::new(),
|
||||
suppress_ids: vec![],
|
||||
respond_to,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -79,15 +79,13 @@ pub struct EditInput {
|
|||
)]
|
||||
pub new_string: String,
|
||||
|
||||
/// When true, replace every occurrence of `old_string` (default false).
|
||||
/// When true, replace every occurrence of `old_string`.
|
||||
#[serde(
|
||||
default,
|
||||
deserialize_with = "crate::types::schema::deserialize_lenient_option_bool"
|
||||
deserialize_with = "crate::types::schema::deserialize_lenient_bool"
|
||||
)]
|
||||
#[schemars(
|
||||
description = "Replace all occurrences of ${{ params.edit.oldString }} (default false)"
|
||||
)]
|
||||
pub replace_all: Option<bool>,
|
||||
#[schemars(description = "Replace all occurrences of ${{ params.edit.oldString }}")]
|
||||
pub replace_all: bool,
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
|
@ -197,7 +195,7 @@ impl xai_tool_runtime::Tool for EditTool {
|
|||
};
|
||||
let tool_call_id = ctx.call_id.as_str().to_owned();
|
||||
|
||||
let replace_all = input.replace_all.unwrap_or(false);
|
||||
let replace_all = input.replace_all;
|
||||
|
||||
// Resolve the model-provided path.
|
||||
let path = resolve_model_path(&cwd, display_cwd.as_deref(), &input.file_path);
|
||||
|
|
@ -521,10 +519,30 @@ mod tests {
|
|||
file_path: file_path.to_string(),
|
||||
old_string: old_string.to_string(),
|
||||
new_string: new_string.to_string(),
|
||||
replace_all: None,
|
||||
replace_all: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_all_defaults_false_and_schema_is_boolean() {
|
||||
let missing: EditInput =
|
||||
serde_json::from_str(r#"{"filePath":"/f","oldString":"a","newString":"b"}"#).unwrap();
|
||||
assert!(!missing.replace_all);
|
||||
|
||||
let nullv: EditInput = serde_json::from_str(
|
||||
r#"{"filePath":"/f","oldString":"a","newString":"b","replaceAll":null}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!nullv.replace_all);
|
||||
|
||||
let schema = serde_json::to_value(schemars::schema_for!(EditInput)).unwrap();
|
||||
// rename_all = camelCase → replaceAll
|
||||
let p = &schema["properties"]["replaceAll"];
|
||||
assert_eq!(p["type"], "boolean", "schema: {schema}");
|
||||
assert_eq!(p["default"], false, "schema: {schema}");
|
||||
assert!(p.get("anyOf").is_none(), "schema: {schema}");
|
||||
}
|
||||
|
||||
// ── Tool metadata ───────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
|
|
@ -560,7 +578,7 @@ mod tests {
|
|||
assert_eq!(input.file_path, "src/main.rs");
|
||||
assert_eq!(input.old_string, "hello");
|
||||
assert_eq!(input.new_string, "goodbye");
|
||||
assert_eq!(input.replace_all, Some(true));
|
||||
assert!(input.replace_all);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -572,7 +590,7 @@ mod tests {
|
|||
});
|
||||
let input: EditInput = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(input.file_path, "test.txt");
|
||||
assert_eq!(input.replace_all, None);
|
||||
assert!(!input.replace_all);
|
||||
}
|
||||
|
||||
// ── Validation ──────────────────────────────────────────────────
|
||||
|
|
@ -816,7 +834,7 @@ mod tests {
|
|||
file_path: "test.txt".to_string(),
|
||||
old_string: "aaa".to_string(),
|
||||
new_string: "ccc".to_string(),
|
||||
replace_all: Some(true),
|
||||
replace_all: true,
|
||||
};
|
||||
let result = xai_tool_runtime::Tool::run(&tool, test_ctx(resources.into_shared()), input)
|
||||
.await
|
||||
|
|
@ -990,7 +1008,7 @@ mod tests {
|
|||
file_path: "test.txt".to_string(),
|
||||
old_string: "foo".to_string(),
|
||||
new_string: "qux".to_string(),
|
||||
replace_all: Some(true),
|
||||
replace_all: true,
|
||||
};
|
||||
let result = xai_tool_runtime::Tool::run(&tool, test_ctx(resources.into_shared()), input)
|
||||
.await
|
||||
|
|
|
|||
|
|
@ -155,7 +155,10 @@ impl WebSearchClient {
|
|||
return Err(xai_tool_runtime::ToolError::unauthorized(format!(
|
||||
"Responses API returned 401 Unauthorized: {body}"
|
||||
))
|
||||
.with_details(serde_json::json!({ "tool_id" : "web_search", "status" : 401, })));
|
||||
.with_details(serde_json::json!({
|
||||
"tool_id": "web_search",
|
||||
"status": 401,
|
||||
})));
|
||||
}
|
||||
if !status.is_success() {
|
||||
let body = response
|
||||
|
|
@ -243,7 +246,10 @@ impl WebSearchClient {
|
|||
return Err(xai_tool_runtime::ToolError::unauthorized(format!(
|
||||
"Responses API returned 401 Unauthorized: {body}"
|
||||
))
|
||||
.with_details(serde_json::json!({ "tool_id" : "web_search", "status" : 401, })));
|
||||
.with_details(serde_json::json!({
|
||||
"tool_id": "web_search",
|
||||
"status": 401,
|
||||
})));
|
||||
}
|
||||
if !status.is_success() {
|
||||
let body = response
|
||||
|
|
@ -411,26 +417,56 @@ mod tests {
|
|||
}
|
||||
#[test]
|
||||
fn test_extract_citations_empty_response() {
|
||||
let response = response_from_json(serde_json::json!(
|
||||
{ "id" : "resp_test", "object" : "response", "created_at" : 1234567890,
|
||||
"status" : "completed", "output" : [], "model" : "test-model" }
|
||||
));
|
||||
let response = response_from_json(serde_json::json!({
|
||||
"id": "resp_test",
|
||||
"object": "response",
|
||||
"created_at": 1234567890,
|
||||
"status": "completed",
|
||||
"output": [],
|
||||
"model": "test-model"
|
||||
}));
|
||||
let citations = extract_citations(&response);
|
||||
assert!(citations.is_empty());
|
||||
}
|
||||
#[test]
|
||||
fn test_extract_citations_with_url_citations() {
|
||||
let response = response_from_json(serde_json::json!(
|
||||
{ "id" : "resp_test", "object" : "response", "created_at" : 1234567890,
|
||||
"status" : "completed", "model" : "test-model", "output" : [{ "type" :
|
||||
"message", "id" : "msg_1", "status" : "completed", "role" : "assistant",
|
||||
"content" : [{ "type" : "output_text", "text" :
|
||||
"Here is some info about Rust.", "annotations" : [{ "type" :
|
||||
"url_citation", "url" : "https://www.rust-lang.org/", "title" :
|
||||
"Rust Programming Language", "start_index" : 0, "end_index" : 10 }, {
|
||||
"type" : "url_citation", "url" : "https://docs.rs/", "title" : "Docs.rs",
|
||||
"start_index" : 11, "end_index" : 20 }] }] }] }
|
||||
));
|
||||
let response = response_from_json(serde_json::json!({
|
||||
"id": "resp_test",
|
||||
"object": "response",
|
||||
"created_at": 1234567890,
|
||||
"status": "completed",
|
||||
"model": "test-model",
|
||||
"output": [
|
||||
{
|
||||
"type": "message",
|
||||
"id": "msg_1",
|
||||
"status": "completed",
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "output_text",
|
||||
"text": "Here is some info about Rust.",
|
||||
"annotations": [
|
||||
{
|
||||
"type": "url_citation",
|
||||
"url": "https://www.rust-lang.org/",
|
||||
"title": "Rust Programming Language",
|
||||
"start_index": 0,
|
||||
"end_index": 10
|
||||
},
|
||||
{
|
||||
"type": "url_citation",
|
||||
"url": "https://docs.rs/",
|
||||
"title": "Docs.rs",
|
||||
"start_index": 11,
|
||||
"end_index": 20
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}));
|
||||
let citations = extract_citations(&response);
|
||||
assert_eq!(citations.len(), 2);
|
||||
assert_eq!(citations[0], "https://www.rust-lang.org/");
|
||||
|
|
@ -438,19 +474,50 @@ mod tests {
|
|||
}
|
||||
#[test]
|
||||
fn test_extract_citations_deduplicates() {
|
||||
let response = response_from_json(serde_json::json!(
|
||||
{ "id" : "resp_test", "object" : "response", "created_at" : 1234567890,
|
||||
"status" : "completed", "model" : "test-model", "output" : [{ "type" :
|
||||
"message", "id" : "msg_1", "status" : "completed", "role" : "assistant",
|
||||
"content" : [{ "type" : "output_text", "text" :
|
||||
"Info with duplicate citations.", "annotations" : [{ "type" :
|
||||
"url_citation", "url" : "https://example.com/page1", "title" : "Page 1",
|
||||
"start_index" : 0, "end_index" : 5 }, { "type" : "url_citation", "url" :
|
||||
"https://example.com/page2", "title" : "Page 2", "start_index" : 6,
|
||||
"end_index" : 10 }, { "type" : "url_citation", "url" :
|
||||
"https://example.com/page1", "title" : "Page 1 Again", "start_index" :
|
||||
11, "end_index" : 15 }] }] }] }
|
||||
));
|
||||
let response = response_from_json(serde_json::json!({
|
||||
"id": "resp_test",
|
||||
"object": "response",
|
||||
"created_at": 1234567890,
|
||||
"status": "completed",
|
||||
"model": "test-model",
|
||||
"output": [
|
||||
{
|
||||
"type": "message",
|
||||
"id": "msg_1",
|
||||
"status": "completed",
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "output_text",
|
||||
"text": "Info with duplicate citations.",
|
||||
"annotations": [
|
||||
{
|
||||
"type": "url_citation",
|
||||
"url": "https://example.com/page1",
|
||||
"title": "Page 1",
|
||||
"start_index": 0,
|
||||
"end_index": 5
|
||||
},
|
||||
{
|
||||
"type": "url_citation",
|
||||
"url": "https://example.com/page2",
|
||||
"title": "Page 2",
|
||||
"start_index": 6,
|
||||
"end_index": 10
|
||||
},
|
||||
{
|
||||
"type": "url_citation",
|
||||
"url": "https://example.com/page1",
|
||||
"title": "Page 1 Again",
|
||||
"start_index": 11,
|
||||
"end_index": 15
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}));
|
||||
let citations = extract_citations(&response);
|
||||
assert_eq!(citations.len(), 2);
|
||||
assert_eq!(citations[0], "https://example.com/page1");
|
||||
|
|
@ -458,19 +525,57 @@ mod tests {
|
|||
}
|
||||
#[test]
|
||||
fn test_extract_citations_multiple_messages() {
|
||||
let response = response_from_json(serde_json::json!(
|
||||
{ "id" : "resp_test", "object" : "response", "created_at" : 1234567890,
|
||||
"status" : "completed", "model" : "test-model", "output" : [{ "type" :
|
||||
"message", "id" : "msg_1", "status" : "completed", "role" : "assistant",
|
||||
"content" : [{ "type" : "output_text", "text" : "First message",
|
||||
"annotations" : [{ "type" : "url_citation", "url" : "https://first.com/",
|
||||
"title" : "First", "start_index" : 0, "end_index" : 5 }] }] }, { "type" :
|
||||
"message", "id" : "msg_2", "status" : "completed", "role" : "assistant",
|
||||
"content" : [{ "type" : "output_text", "text" : "Second message",
|
||||
"annotations" : [{ "type" : "url_citation", "url" :
|
||||
"https://second.com/", "title" : "Second", "start_index" : 0, "end_index"
|
||||
: 6 }] }] }] }
|
||||
));
|
||||
let response = response_from_json(serde_json::json!({
|
||||
"id": "resp_test",
|
||||
"object": "response",
|
||||
"created_at": 1234567890,
|
||||
"status": "completed",
|
||||
"model": "test-model",
|
||||
"output": [
|
||||
{
|
||||
"type": "message",
|
||||
"id": "msg_1",
|
||||
"status": "completed",
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "output_text",
|
||||
"text": "First message",
|
||||
"annotations": [
|
||||
{
|
||||
"type": "url_citation",
|
||||
"url": "https://first.com/",
|
||||
"title": "First",
|
||||
"start_index": 0,
|
||||
"end_index": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "message",
|
||||
"id": "msg_2",
|
||||
"status": "completed",
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "output_text",
|
||||
"text": "Second message",
|
||||
"annotations": [
|
||||
{
|
||||
"type": "url_citation",
|
||||
"url": "https://second.com/",
|
||||
"title": "Second",
|
||||
"start_index": 0,
|
||||
"end_index": 6
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}));
|
||||
let citations = extract_citations(&response);
|
||||
assert_eq!(citations.len(), 2);
|
||||
assert_eq!(citations[0], "https://first.com/");
|
||||
|
|
@ -478,14 +583,36 @@ mod tests {
|
|||
}
|
||||
#[test]
|
||||
fn test_extract_citations_ignores_non_url_annotations() {
|
||||
let response = response_from_json(serde_json::json!(
|
||||
{ "id" : "resp_test", "object" : "response", "created_at" : 1234567890,
|
||||
"status" : "completed", "model" : "test-model", "output" : [{ "type" :
|
||||
"message", "id" : "msg_1", "status" : "completed", "role" : "assistant",
|
||||
"content" : [{ "type" : "output_text", "text" : "Some text",
|
||||
"annotations" : [{ "type" : "url_citation", "url" : "https://valid.com/",
|
||||
"title" : "Valid", "start_index" : 0, "end_index" : 4 }] }] }] }
|
||||
));
|
||||
let response = response_from_json(serde_json::json!({
|
||||
"id": "resp_test",
|
||||
"object": "response",
|
||||
"created_at": 1234567890,
|
||||
"status": "completed",
|
||||
"model": "test-model",
|
||||
"output": [
|
||||
{
|
||||
"type": "message",
|
||||
"id": "msg_1",
|
||||
"status": "completed",
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "output_text",
|
||||
"text": "Some text",
|
||||
"annotations": [
|
||||
{
|
||||
"type": "url_citation",
|
||||
"url": "https://valid.com/",
|
||||
"title": "Valid",
|
||||
"start_index": 0,
|
||||
"end_index": 4
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}));
|
||||
let citations = extract_citations(&response);
|
||||
assert_eq!(citations.len(), 1);
|
||||
assert_eq!(citations[0], "https://valid.com/");
|
||||
|
|
@ -510,14 +637,24 @@ mod tests {
|
|||
Mock::given(method("POST"))
|
||||
.and(path("/responses"))
|
||||
.and(header("Authorization", "Bearer static-key-from-config"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!(
|
||||
{ "id" : "resp_test", "object" : "response", "created_at" :
|
||||
1234567890, "status" : "completed", "model" : "test-model",
|
||||
"output" : [{ "type" : "message", "id" : "msg_1", "status" :
|
||||
"completed", "role" : "assistant", "content" : [{ "type" :
|
||||
"output_text", "text" : "search result", "annotations" : []
|
||||
}] }] }
|
||||
)))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"id": "resp_test",
|
||||
"object": "response",
|
||||
"created_at": 1234567890,
|
||||
"status": "completed",
|
||||
"model": "test-model",
|
||||
"output": [{
|
||||
"type": "message",
|
||||
"id": "msg_1",
|
||||
"status": "completed",
|
||||
"role": "assistant",
|
||||
"content": [{
|
||||
"type": "output_text",
|
||||
"text": "search result",
|
||||
"annotations": []
|
||||
}]
|
||||
}]
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
let config = WebSearchConfig::Enabled {
|
||||
|
|
@ -550,14 +687,24 @@ mod tests {
|
|||
Mock::given(method("POST"))
|
||||
.and(path("/responses"))
|
||||
.and(header("Authorization", "Bearer fresh-key-from-provider"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!(
|
||||
{ "id" : "resp_test", "object" : "response", "created_at" :
|
||||
1234567890, "status" : "completed", "model" : "test-model",
|
||||
"output" : [{ "type" : "message", "id" : "msg_1", "status" :
|
||||
"completed", "role" : "assistant", "content" : [{ "type" :
|
||||
"output_text", "text" : "fresh result", "annotations" : [] }]
|
||||
}] }
|
||||
)))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"id": "resp_test",
|
||||
"object": "response",
|
||||
"created_at": 1234567890,
|
||||
"status": "completed",
|
||||
"model": "test-model",
|
||||
"output": [{
|
||||
"type": "message",
|
||||
"id": "msg_1",
|
||||
"status": "completed",
|
||||
"role": "assistant",
|
||||
"content": [{
|
||||
"type": "output_text",
|
||||
"text": "fresh result",
|
||||
"annotations": []
|
||||
}]
|
||||
}]
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
let config = WebSearchConfig::Enabled {
|
||||
|
|
@ -577,13 +724,28 @@ mod tests {
|
|||
}
|
||||
#[test]
|
||||
fn test_extract_citations_no_annotations() {
|
||||
let response = response_from_json(serde_json::json!(
|
||||
{ "id" : "resp_test", "object" : "response", "created_at" : 1234567890,
|
||||
"status" : "completed", "model" : "test-model", "output" : [{ "type" :
|
||||
"message", "id" : "msg_1", "status" : "completed", "role" : "assistant",
|
||||
"content" : [{ "type" : "output_text", "text" :
|
||||
"Plain text with no annotations", "annotations" : [] }] }] }
|
||||
));
|
||||
let response = response_from_json(serde_json::json!({
|
||||
"id": "resp_test",
|
||||
"object": "response",
|
||||
"created_at": 1234567890,
|
||||
"status": "completed",
|
||||
"model": "test-model",
|
||||
"output": [
|
||||
{
|
||||
"type": "message",
|
||||
"id": "msg_1",
|
||||
"status": "completed",
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "output_text",
|
||||
"text": "Plain text with no annotations",
|
||||
"annotations": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}));
|
||||
let citations = extract_citations(&response);
|
||||
assert!(citations.is_empty());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -140,7 +140,7 @@ mod tests {
|
|||
}
|
||||
#[test]
|
||||
fn canonical_omits_absent_options_not_null() {
|
||||
let grok = parse(serde_json::json!({ "variant" : "ReadFile", "target_file" : "/a" }));
|
||||
let grok = parse(serde_json::json!({"variant":"ReadFile","target_file":"/a"}));
|
||||
let g = canonical_input(&grok).unwrap();
|
||||
let keys: Vec<&String> = g.as_object().unwrap().keys().collect();
|
||||
assert_eq!(
|
||||
|
|
|
|||
|
|
@ -29,6 +29,12 @@ pub struct ResourcesPersistence {
|
|||
noop: bool,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) type ControlledSave = (
|
||||
serde_json::Value,
|
||||
tokio::sync::oneshot::Sender<io::Result<()>>,
|
||||
);
|
||||
|
||||
enum ResourcesPersistenceCommand {
|
||||
/// Write this serialized Resources value to disk
|
||||
Save(serde_json::Value),
|
||||
|
|
@ -51,6 +57,37 @@ impl ResourcesPersistence {
|
|||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn controlled() -> (Self, tokio::sync::mpsc::UnboundedReceiver<ControlledSave>) {
|
||||
let (tx, mut commands) =
|
||||
tokio::sync::mpsc::unbounded_channel::<ResourcesPersistenceCommand>();
|
||||
let (observed_tx, observed_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
tokio::spawn(async move {
|
||||
while let Some(command) = commands.recv().await {
|
||||
match command {
|
||||
ResourcesPersistenceCommand::Save(_) => {}
|
||||
ResourcesPersistenceCommand::SaveAndFlush {
|
||||
snapshot,
|
||||
respond_to,
|
||||
} => {
|
||||
let _ = observed_tx.send((snapshot, respond_to));
|
||||
}
|
||||
ResourcesPersistenceCommand::Flush(done) => {
|
||||
let _ = done.send(());
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
(
|
||||
Self {
|
||||
state_path: PathBuf::from("/dev/null"),
|
||||
tx,
|
||||
noop: false,
|
||||
},
|
||||
observed_rx,
|
||||
)
|
||||
}
|
||||
|
||||
/// Create a new persistence handle and spawn the background writer task.
|
||||
pub fn new(state_path: PathBuf) -> Self {
|
||||
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
|
|
|
|||
|
|
@ -141,8 +141,7 @@ mod tests {
|
|||
assert_eq!(err.field_path(), "tools[3].params_json");
|
||||
assert!(matches!(
|
||||
&err.kind,
|
||||
ToolConfigEntryErrorKind::ParamsJsonParse { raw, .. }
|
||||
if raw == "{not json"
|
||||
ToolConfigEntryErrorKind::ParamsJsonParse { raw, .. } if raw == "{not json"
|
||||
));
|
||||
}
|
||||
|
||||
|
|
@ -182,8 +181,7 @@ if raw == "{not json"
|
|||
assert!(
|
||||
matches!(
|
||||
&err.kind,
|
||||
ToolConfigEntryErrorKind::NameOverrideInvalid { name: n, .. }
|
||||
if n == name
|
||||
ToolConfigEntryErrorKind::NameOverrideInvalid { name: n, .. } if n == name
|
||||
),
|
||||
"name={name:?} kind={:?}",
|
||||
err.kind
|
||||
|
|
|
|||
|
|
@ -103,9 +103,7 @@ where
|
|||
};
|
||||
let kind = ToolKind::deserialize(serde::de::value::StrDeserializer::<D::Error>::new(&raw))?;
|
||||
if kind == ToolKind::Other && raw != "other" {
|
||||
tracing::warn!(
|
||||
kind = % raw, "unknown tool kind in config; treating as \"other\""
|
||||
);
|
||||
tracing::warn!(kind = %raw, "unknown tool kind in config; treating as \"other\"");
|
||||
}
|
||||
Ok(Some(kind))
|
||||
}
|
||||
|
|
@ -757,11 +755,14 @@ impl ToolRegistryBuilder {
|
|||
.map(|(name, e)| {
|
||||
(
|
||||
name.as_str(),
|
||||
serde_json::json!(
|
||||
{ "namespace" : e.namespace, "id" : e.id, "kind" : e.kind,
|
||||
"default_params" : e.default_params, "input_schema" : e.input_schema,
|
||||
"requires" : e.requires, }
|
||||
),
|
||||
serde_json::json!({
|
||||
"namespace": e.namespace,
|
||||
"id": e.id,
|
||||
"kind": e.kind,
|
||||
"default_params": e.default_params,
|
||||
"input_schema": e.input_schema,
|
||||
"requires": e.requires,
|
||||
}),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
|
@ -788,8 +789,8 @@ impl ToolRegistryBuilder {
|
|||
for tool_config in &config.tools {
|
||||
let Some(entry) = self.tools.get(tool_config.id.as_str()) else {
|
||||
tracing::warn!(
|
||||
tool_id = % tool_config.id, registered_keys = ? self.tools.keys()
|
||||
.collect::< Vec < _ >> (),
|
||||
tool_id = %tool_config.id,
|
||||
registered_keys = ?self.tools.keys().collect::<Vec<_>>(),
|
||||
"validate_config: tool NOT FOUND in registry"
|
||||
);
|
||||
errors.push(
|
||||
|
|
@ -1192,6 +1193,7 @@ impl ToolRegistryBuilder {
|
|||
cancel_token: cancel_token.clone(),
|
||||
clock: Default::default(),
|
||||
pending_removal: None,
|
||||
blocked_expiries: Default::default(),
|
||||
};
|
||||
tokio::spawn(actor.run());
|
||||
}
|
||||
|
|
@ -1486,22 +1488,46 @@ impl FinalizedToolset {
|
|||
let tool_name = tool_name.to_owned();
|
||||
let tool_call_id = tool_call_id.to_owned();
|
||||
Box::pin(async_stream::stream! {
|
||||
let parts = match this.prepare_dispatch(& tool_name, tool_args, &
|
||||
tool_call_id, cwd_override,) { Ok(parts) => parts, Err(e) => { yield
|
||||
xai_tool_runtime::ToolStreamItem::Terminal(Err(e)); return; } }; let
|
||||
DispatchParts { lr_handle, ctx, canonical_params, output_converter,
|
||||
effective_tool_name, } = parts; let mut inner = lr_handle.execute(ctx,
|
||||
canonical_params). await; while let Some(item) = inner.next(). await {
|
||||
match item { xai_tool_runtime::ToolStreamItem::Progress(p) => { yield
|
||||
xai_tool_runtime::ToolStreamItem::Progress(p); }
|
||||
xai_tool_runtime::ToolStreamItem::Terminal(Err(e)) => { yield
|
||||
xai_tool_runtime::ToolStreamItem::Terminal(Err(e)); return; }
|
||||
xai_tool_runtime::ToolStreamItem::Terminal(Ok(typed)) => { let run_result
|
||||
= this.finalize_output(typed.value, & output_converter,
|
||||
effective_tool_name). await; yield
|
||||
xai_tool_runtime::ToolStreamItem::Terminal(run_result); return; } } }
|
||||
yield
|
||||
xai_tool_runtime::ToolStreamItem::Terminal(Err(stream_no_terminal_error()));
|
||||
let parts = match this.prepare_dispatch(
|
||||
&tool_name,
|
||||
tool_args,
|
||||
&tool_call_id,
|
||||
cwd_override,
|
||||
) {
|
||||
Ok(parts) => parts,
|
||||
Err(e) => {
|
||||
yield xai_tool_runtime::ToolStreamItem::Terminal(Err(e));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let DispatchParts {
|
||||
lr_handle,
|
||||
ctx,
|
||||
canonical_params,
|
||||
output_converter,
|
||||
effective_tool_name,
|
||||
} = parts;
|
||||
|
||||
let mut inner = lr_handle.execute(ctx, canonical_params).await;
|
||||
while let Some(item) = inner.next().await {
|
||||
match item {
|
||||
xai_tool_runtime::ToolStreamItem::Progress(p) => {
|
||||
yield xai_tool_runtime::ToolStreamItem::Progress(p);
|
||||
}
|
||||
xai_tool_runtime::ToolStreamItem::Terminal(Err(e)) => {
|
||||
yield xai_tool_runtime::ToolStreamItem::Terminal(Err(e));
|
||||
return;
|
||||
}
|
||||
xai_tool_runtime::ToolStreamItem::Terminal(Ok(typed)) => {
|
||||
let run_result = this
|
||||
.finalize_output(typed.value, &output_converter, effective_tool_name)
|
||||
.await;
|
||||
yield xai_tool_runtime::ToolStreamItem::Terminal(run_result);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
yield xai_tool_runtime::ToolStreamItem::Terminal(Err(stream_no_terminal_error()));
|
||||
})
|
||||
}
|
||||
/// Pre-dispatch setup shared by [`call`] / [`call_streaming`].
|
||||
|
|
@ -1825,9 +1851,9 @@ fn explain_requirement_failure(
|
|||
"unsatisfied requirements".to_string()
|
||||
} else {
|
||||
format!(
|
||||
"enabled_background=true requires {} so background bash tasks can be observed and cancelled",
|
||||
missing.join(" and ")
|
||||
)
|
||||
"enabled_background=true requires {} so background bash tasks can be observed and cancelled",
|
||||
missing.join(" and ")
|
||||
)
|
||||
};
|
||||
RequirementError::new(fq_tool_id, message)
|
||||
.with_field_path("params.enabled_background")
|
||||
|
|
@ -1848,9 +1874,9 @@ fn explain_requirement_failure(
|
|||
RequirementError::new(
|
||||
fq_tool_id,
|
||||
format!(
|
||||
"task requires {} so spawned background subagents can be monitored and cancelled",
|
||||
missing.join(" and ")
|
||||
),
|
||||
"task requires {} so spawned background subagents can be monitored and cancelled",
|
||||
missing.join(" and ")
|
||||
),
|
||||
)
|
||||
.with_field_path("tools")
|
||||
.with_expected("include get_task_output and kill_task")
|
||||
|
|
@ -2060,11 +2086,10 @@ mod tests {
|
|||
ToolConfig {
|
||||
id: "GrokBuild:search_replace".to_string(),
|
||||
params: Some(
|
||||
serde_json::json!({
|
||||
"skip_read_before_edit" : true })
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.clone(),
|
||||
serde_json::json!({ "skip_read_before_edit": true })
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.clone(),
|
||||
),
|
||||
name_override: None,
|
||||
params_name_overrides: None,
|
||||
|
|
@ -2084,10 +2109,12 @@ mod tests {
|
|||
let result = toolset
|
||||
.call(
|
||||
"search_replace",
|
||||
serde_json::json!(
|
||||
{ "file_path" : "test.txt", "old_string" : "aaa", "new_string" :
|
||||
"ccc", "replace_all" : false, }
|
||||
),
|
||||
serde_json::json!({
|
||||
"file_path": "test.txt",
|
||||
"old_string": "aaa",
|
||||
"new_string": "ccc",
|
||||
"replace_all": false,
|
||||
}),
|
||||
"test-call",
|
||||
None,
|
||||
)
|
||||
|
|
@ -2290,7 +2317,7 @@ mod tests {
|
|||
});
|
||||
let merged = merge_tool_meta(
|
||||
&toolset,
|
||||
Some(serde_json::json!({ "bash_mode" : true })),
|
||||
Some(serde_json::json!({"bash_mode": true})),
|
||||
"run_terminal_cmd",
|
||||
Some(&bash),
|
||||
)
|
||||
|
|
@ -2300,7 +2327,7 @@ mod tests {
|
|||
assert_eq!(merged[TOOL_META_KEY]["input"]["command"], "ls");
|
||||
let unchanged = merge_tool_meta(
|
||||
&toolset,
|
||||
Some(serde_json::json!({ "backend" : true })),
|
||||
Some(serde_json::json!({"backend": true})),
|
||||
"not_a_registered_tool",
|
||||
None,
|
||||
)
|
||||
|
|
@ -2340,11 +2367,11 @@ mod tests {
|
|||
let parse = |v: serde_json::Value| -> ToolConfig {
|
||||
serde_json::from_value(v).expect("ToolConfig deserializes")
|
||||
};
|
||||
let known = parse(serde_json::json!({ "id" : "GrokBuild:read_file", "kind" : "read" }));
|
||||
let known = parse(serde_json::json!({"id": "GrokBuild:read_file", "kind": "read"}));
|
||||
assert_eq!(known.kind, Some(ToolKind::Read));
|
||||
let typo = parse(serde_json::json!({ "id" : "GrokBuild:read_file", "kind" : "raed" }));
|
||||
let typo = parse(serde_json::json!({"id": "GrokBuild:read_file", "kind": "raed"}));
|
||||
assert_eq!(typo.kind, Some(ToolKind::Other));
|
||||
let absent = parse(serde_json::json!({ "id" : "GrokBuild:read_file" }));
|
||||
let absent = parse(serde_json::json!({"id": "GrokBuild:read_file"}));
|
||||
assert_eq!(absent.kind, None);
|
||||
}
|
||||
/// End-to-end: a `params_name_overrides` rename of `old_string` must flow
|
||||
|
|
@ -2355,6 +2382,7 @@ mod tests {
|
|||
let builder = ToolRegistryBuilder::new();
|
||||
let config = ToolServerConfig {
|
||||
tools: vec![
|
||||
// read_file satisfies search_replace's Read requirement.
|
||||
ToolConfig {
|
||||
id: "GrokBuild:read_file".to_string(),
|
||||
params: None,
|
||||
|
|
@ -2438,7 +2466,7 @@ mod tests {
|
|||
},
|
||||
ToolConfig {
|
||||
id: "GrokBuild:search_replace".to_string(),
|
||||
params: None,
|
||||
params: None, // default: skip_read_before_edit = false
|
||||
name_override: None,
|
||||
params_name_overrides: None,
|
||||
description_override: None,
|
||||
|
|
@ -2458,7 +2486,7 @@ mod tests {
|
|||
toolset
|
||||
.call(
|
||||
"read_file",
|
||||
serde_json::json!({ "target_file" : * fname }),
|
||||
serde_json::json!({ "target_file": *fname }),
|
||||
"read-call",
|
||||
None,
|
||||
)
|
||||
|
|
@ -2468,10 +2496,12 @@ mod tests {
|
|||
let result = toolset
|
||||
.call(
|
||||
"search_replace",
|
||||
serde_json::json!(
|
||||
{ "file_path" : "dup.txt", "old_string" : "aaa", "new_string" :
|
||||
"ccc", "replace_all" : false, }
|
||||
),
|
||||
serde_json::json!({
|
||||
"file_path": "dup.txt",
|
||||
"old_string": "aaa",
|
||||
"new_string": "ccc",
|
||||
"replace_all": false,
|
||||
}),
|
||||
"call-2",
|
||||
None,
|
||||
)
|
||||
|
|
@ -2493,10 +2523,11 @@ mod tests {
|
|||
let result = toolset
|
||||
.call(
|
||||
"search_replace",
|
||||
serde_json::json!(
|
||||
{ "file_path" : "no_match.txt", "old_string" : "nonexistent_string",
|
||||
"new_string" : "replacement", }
|
||||
),
|
||||
serde_json::json!({
|
||||
"file_path": "no_match.txt",
|
||||
"old_string": "nonexistent_string",
|
||||
"new_string": "replacement",
|
||||
}),
|
||||
"call-3",
|
||||
None,
|
||||
)
|
||||
|
|
@ -2542,7 +2573,7 @@ mod tests {
|
|||
ToolConfig {
|
||||
id: "GrokBuildConcise:run_terminal_cmd".to_string(),
|
||||
params: Some(
|
||||
serde_json::json!({ "enabled_background" : true })
|
||||
serde_json::json!({ "enabled_background": true })
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.clone(),
|
||||
|
|
@ -2577,7 +2608,7 @@ mod tests {
|
|||
let result = toolset
|
||||
.call(
|
||||
"read_file",
|
||||
serde_json::json!({ "target_file" : "hello.txt" }),
|
||||
serde_json::json!({ "target_file": "hello.txt" }),
|
||||
"call-concise-1",
|
||||
None,
|
||||
)
|
||||
|
|
@ -2673,7 +2704,7 @@ mod tests {
|
|||
ToolConfig {
|
||||
id: "Codex:read_file".to_string(),
|
||||
params: None,
|
||||
name_override: None,
|
||||
name_override: None, // both resolve to "read_file"
|
||||
params_name_overrides: None,
|
||||
description_override: None,
|
||||
behavior_version: None,
|
||||
|
|
@ -2698,8 +2729,9 @@ mod tests {
|
|||
tools: vec![ToolConfig {
|
||||
id: "GrokBuild:run_terminal_cmd".to_string(),
|
||||
params: Some(
|
||||
serde_json::from_value(serde_json::json!({ "enabled_background" :
|
||||
"yes" }))
|
||||
serde_json::from_value(serde_json::json!({
|
||||
"enabled_background": "yes"
|
||||
}))
|
||||
.unwrap(),
|
||||
),
|
||||
name_override: None,
|
||||
|
|
@ -2728,7 +2760,10 @@ mod tests {
|
|||
tools: vec![ToolConfig {
|
||||
id: "GrokBuildHashline:hashline_read".to_string(),
|
||||
params: Some(
|
||||
serde_json::from_value(serde_json::json!({ "hash_len" : 0 })).unwrap(),
|
||||
serde_json::from_value(serde_json::json!({
|
||||
"hash_len": 0
|
||||
}))
|
||||
.unwrap(),
|
||||
),
|
||||
name_override: None,
|
||||
params_name_overrides: None,
|
||||
|
|
@ -2756,7 +2791,7 @@ mod tests {
|
|||
ToolConfig {
|
||||
id: "GrokBuild:read_file".to_string(),
|
||||
params: None,
|
||||
name_override: None,
|
||||
name_override: None, // client_name = "read_file"
|
||||
params_name_overrides: None,
|
||||
description_override: None,
|
||||
behavior_version: None,
|
||||
|
|
@ -2765,7 +2800,7 @@ mod tests {
|
|||
ToolConfig {
|
||||
id: "Codex:read_file".to_string(),
|
||||
params: None,
|
||||
name_override: Some("codex_read_file".to_string()),
|
||||
name_override: Some("codex_read_file".to_string()), // disambiguated
|
||||
params_name_overrides: None,
|
||||
description_override: None,
|
||||
behavior_version: None,
|
||||
|
|
@ -2879,16 +2914,16 @@ mod tests {
|
|||
FakeMcpTool {
|
||||
description: "Create or update a Linear issue".into(),
|
||||
},
|
||||
Some(serde_json::json!({ "type" : "object", "properties" : {} })),
|
||||
Some(serde_json::json!({"type": "object", "properties": {}})),
|
||||
)
|
||||
.unwrap();
|
||||
let result = toolset
|
||||
.call(
|
||||
"use_tool",
|
||||
serde_json::json!(
|
||||
{ "tool_name" : "linear__save_issue", "tool_input" : { "title" :
|
||||
"hello" } }
|
||||
),
|
||||
serde_json::json!({
|
||||
"tool_name": "linear__save_issue",
|
||||
"tool_input": {"title": "hello"}
|
||||
}),
|
||||
"call-1",
|
||||
None,
|
||||
)
|
||||
|
|
@ -3003,7 +3038,7 @@ mod tests {
|
|||
.register_tool(
|
||||
"stub".to_string(),
|
||||
NonStreamingStub,
|
||||
Some(serde_json::json!({ "type" : "object", "properties" : {} })),
|
||||
Some(serde_json::json!({"type": "object", "properties": {}})),
|
||||
)
|
||||
.unwrap();
|
||||
let result = toolset
|
||||
|
|
@ -3036,7 +3071,7 @@ mod tests {
|
|||
.register_tool(
|
||||
"streamer".to_string(),
|
||||
StreamingStub,
|
||||
Some(serde_json::json!({ "type" : "object", "properties" : {} })),
|
||||
Some(serde_json::json!({"type": "object", "properties": {}})),
|
||||
)
|
||||
.unwrap();
|
||||
let mut stream = toolset.call_streaming("streamer", serde_json::json!({}), "call-b", None);
|
||||
|
|
@ -3150,7 +3185,7 @@ mod tests {
|
|||
.register_tool(
|
||||
"no_terminal".to_string(),
|
||||
NoTerminalStub,
|
||||
Some(serde_json::json!({ "type" : "object", "properties" : {} })),
|
||||
Some(serde_json::json!({"type": "object", "properties": {}})),
|
||||
)
|
||||
.unwrap();
|
||||
let err = toolset
|
||||
|
|
@ -3182,7 +3217,7 @@ mod tests {
|
|||
FakeMcpTool {
|
||||
description: "Create or update a Linear issue".into(),
|
||||
},
|
||||
Some(serde_json::json!({ "type" : "object", "properties" : {} })),
|
||||
Some(serde_json::json!({"type": "object", "properties": {}})),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(toolset.tool_definitions().len(), 3);
|
||||
|
|
@ -3359,7 +3394,7 @@ mod tests {
|
|||
tools: vec![ToolConfig {
|
||||
id: "GrokBuild:run_terminal_cmd".to_string(),
|
||||
params: Some(
|
||||
serde_json::json!({ "enabled_background" : false })
|
||||
serde_json::json!({ "enabled_background": false })
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.clone(),
|
||||
|
|
@ -3410,7 +3445,7 @@ mod tests {
|
|||
ToolConfig {
|
||||
id: "GrokBuild:run_terminal_cmd".to_string(),
|
||||
params: Some(
|
||||
serde_json::json!({ "enabled_background" : true })
|
||||
serde_json::json!({ "enabled_background": true })
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.clone(),
|
||||
|
|
@ -3550,11 +3585,10 @@ mod tests {
|
|||
tools: vec![ToolConfig {
|
||||
id: "GrokBuild:run_terminal_cmd".to_string(),
|
||||
params: Some(
|
||||
serde_json::json!({ "enabled_background" : false,
|
||||
"auto_background_on_timeout" : true })
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.clone(),
|
||||
serde_json::json!({ "enabled_background": false, "auto_background_on_timeout": true })
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.clone(),
|
||||
),
|
||||
name_override: None,
|
||||
params_name_overrides: None,
|
||||
|
|
@ -3587,11 +3621,10 @@ mod tests {
|
|||
tools: vec![ToolConfig {
|
||||
id: "GrokBuild:run_terminal_cmd".to_string(),
|
||||
params: Some(
|
||||
serde_json::json!({ "enabled_background" : false,
|
||||
"auto_background_on_timeout" : false })
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.clone(),
|
||||
serde_json::json!({ "enabled_background": false, "auto_background_on_timeout": false })
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.clone(),
|
||||
),
|
||||
name_override: None,
|
||||
params_name_overrides: None,
|
||||
|
|
@ -3633,11 +3666,10 @@ mod tests {
|
|||
tools: vec![ToolConfig {
|
||||
id: "GrokBuild:run_terminal_cmd".to_string(),
|
||||
params: Some(
|
||||
serde_json::json!({ "enabled_background" : false,
|
||||
"auto_background_on_timeout" : false })
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.clone(),
|
||||
serde_json::json!({ "enabled_background": false, "auto_background_on_timeout": false })
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.clone(),
|
||||
),
|
||||
name_override: None,
|
||||
params_name_overrides: None,
|
||||
|
|
@ -4084,11 +4116,10 @@ mod tests {
|
|||
ToolConfig {
|
||||
id: "GrokBuildHashline:hashline_read".to_owned(),
|
||||
params: Some(
|
||||
serde_json::json!({ "scheme" : "chunk", "hash_len" : 2, "chunk_size"
|
||||
: 16 })
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.clone(),
|
||||
serde_json::json!({"scheme": "chunk", "hash_len": 2, "chunk_size": 16})
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.clone(),
|
||||
),
|
||||
name_override: None,
|
||||
params_name_overrides: None,
|
||||
|
|
@ -4124,7 +4155,7 @@ mod tests {
|
|||
ToolConfig {
|
||||
id: "GrokBuild:run_terminal_cmd".to_owned(),
|
||||
params: Some(
|
||||
serde_json::json!({ "enabled_background" : true })
|
||||
serde_json::json!({ "enabled_background": true })
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.clone(),
|
||||
|
|
@ -4164,7 +4195,7 @@ mod tests {
|
|||
let result = bridge
|
||||
.call(
|
||||
"list_dir",
|
||||
serde_json::json!({ "target_directory" : tmp.path().to_str().unwrap() }),
|
||||
serde_json::json!({ "target_directory": tmp.path().to_str().unwrap() }),
|
||||
"test-call-id",
|
||||
)
|
||||
.await
|
||||
|
|
@ -4183,9 +4214,7 @@ mod tests {
|
|||
let test_dir = tmp.path().join("testdir");
|
||||
std::fs::create_dir_all(&test_dir).unwrap();
|
||||
std::fs::write(test_dir.join("parity.txt"), "test").unwrap();
|
||||
let args = serde_json::json!(
|
||||
{ "target_directory" : test_dir.to_str().unwrap() }
|
||||
);
|
||||
let args = serde_json::json!({ "target_directory": test_dir.to_str().unwrap() });
|
||||
let hub_bridge = grok_build_bridge(&tmp).await;
|
||||
let hub_result = hub_bridge
|
||||
.call("list_dir", args.clone(), "hub-call")
|
||||
|
|
@ -4195,9 +4224,8 @@ mod tests {
|
|||
let legacy_test_dir = legacy_tmp.path().join("testdir");
|
||||
std::fs::create_dir_all(&legacy_test_dir).unwrap();
|
||||
std::fs::write(legacy_test_dir.join("parity.txt"), "test").unwrap();
|
||||
let legacy_args = serde_json::json!(
|
||||
{ "target_directory" : legacy_test_dir.to_str().unwrap() }
|
||||
);
|
||||
let legacy_args =
|
||||
serde_json::json!({ "target_directory": legacy_test_dir.to_str().unwrap() });
|
||||
let builder = ToolRegistryBuilder::new();
|
||||
let config = ToolServerConfig {
|
||||
tools: vec![ToolConfig::for_tool::<grok_build::ListDirTool>()],
|
||||
|
|
@ -4231,7 +4259,7 @@ mod tests {
|
|||
bridge
|
||||
.call(
|
||||
"read_file",
|
||||
serde_json::json!({ "target_file" : file.to_str().unwrap() }),
|
||||
serde_json::json!({ "target_file": file.to_str().unwrap() }),
|
||||
"read-call",
|
||||
)
|
||||
.await
|
||||
|
|
@ -4239,10 +4267,11 @@ mod tests {
|
|||
let result = bridge
|
||||
.call(
|
||||
"search_replace",
|
||||
serde_json::json!(
|
||||
{ "file_path" : file.to_str().unwrap(), "old_string" : "hello",
|
||||
"new_string" : "goodbye" }
|
||||
),
|
||||
serde_json::json!({
|
||||
"file_path": file.to_str().unwrap(),
|
||||
"old_string": "hello",
|
||||
"new_string": "goodbye"
|
||||
}),
|
||||
"edit-call",
|
||||
)
|
||||
.await
|
||||
|
|
@ -4263,10 +4292,10 @@ mod tests {
|
|||
let result = bridge
|
||||
.call(
|
||||
"run_terminal_cmd",
|
||||
serde_json::json!(
|
||||
{ "command" : "echo hub_dispatch_test_sentinel", "description" :
|
||||
"test" }
|
||||
),
|
||||
serde_json::json!({
|
||||
"command": "echo hub_dispatch_test_sentinel",
|
||||
"description": "test"
|
||||
}),
|
||||
"bash-call",
|
||||
)
|
||||
.await
|
||||
|
|
@ -4423,7 +4452,7 @@ mod tests {
|
|||
let parts = toolset
|
||||
.prepare_dispatch(
|
||||
"read_file",
|
||||
serde_json::json!({ "target_file" : "noop" }),
|
||||
serde_json::json!({"target_file": "noop"}),
|
||||
"test-call",
|
||||
None,
|
||||
)
|
||||
|
|
@ -4441,7 +4470,7 @@ mod tests {
|
|||
let parts = toolset
|
||||
.prepare_dispatch(
|
||||
"read_file",
|
||||
serde_json::json!({ "target_file" : "noop" }),
|
||||
serde_json::json!({"target_file": "noop"}),
|
||||
"test-call",
|
||||
None,
|
||||
)
|
||||
|
|
@ -4466,7 +4495,7 @@ mod tests {
|
|||
tools: vec![ToolConfig {
|
||||
id: "GrokBuild:run_terminal_cmd".to_string(),
|
||||
params: Some(
|
||||
serde_json::json!({ "enabled_background" : false })
|
||||
serde_json::json!({"enabled_background": false})
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.clone(),
|
||||
|
|
@ -4494,10 +4523,10 @@ mod tests {
|
|||
);
|
||||
let mut stream = toolset.call_streaming(
|
||||
"run_terminal_cmd",
|
||||
serde_json::json!(
|
||||
{ "command" : "for i in 1 2 3; do echo $i; sleep 0.1; done",
|
||||
"description" : "stream progress test" }
|
||||
),
|
||||
serde_json::json!({
|
||||
"command": "for i in 1 2 3; do echo $i; sleep 0.1; done",
|
||||
"description": "stream progress test"
|
||||
}),
|
||||
"test-call",
|
||||
None,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -643,11 +643,14 @@ impl Reminder for TaskCompletionReminder {
|
|||
.chain(&reserved_ids)
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
let (terminal, event_sender) = {
|
||||
let (terminal, event_sender, session_id) = {
|
||||
let res = resources.lock().await;
|
||||
(
|
||||
res.get::<Terminal>().map(|t| t.0.clone()),
|
||||
res.get::<SubagentEventSender>().cloned(),
|
||||
res.get::<crate::implementations::grok_build::task::types::SessionIdResource>()
|
||||
.map(|s| s.0.clone())
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
};
|
||||
let mut reminders = Vec::new();
|
||||
|
|
@ -730,6 +733,7 @@ impl Reminder for TaskCompletionReminder {
|
|||
if sender
|
||||
.0
|
||||
.send(SubagentEvent::Completions(SubagentCompletionsRequest {
|
||||
session_id,
|
||||
suppress_ids,
|
||||
respond_to: tx,
|
||||
}))
|
||||
|
|
@ -1434,6 +1438,7 @@ mod tests {
|
|||
fn make_subagent_completion(id: &str, success: bool) -> SubagentCompletionSummary {
|
||||
SubagentCompletionSummary {
|
||||
subagent_id: id.into(),
|
||||
owner_session_id: String::new(),
|
||||
subagent_type: "general-purpose".into(),
|
||||
description: "test task".into(),
|
||||
success,
|
||||
|
|
@ -1915,8 +1920,9 @@ mod tests {
|
|||
"batch must lead with event + monitor counts and default tool hint: {batched}"
|
||||
);
|
||||
assert!(
|
||||
batched
|
||||
.contains("<monitor description=\"alpha\" task_id=\"task-0\">\n[1] a first\n[2] a second\n</monitor>"),
|
||||
batched.contains(
|
||||
"<monitor description=\"alpha\" task_id=\"task-0\">\n[1] a first\n[2] a second\n</monitor>"
|
||||
),
|
||||
"task-0 group: description once on the tag, ordinal tick labels: {batched}"
|
||||
);
|
||||
assert!(
|
||||
|
|
|
|||
|
|
@ -126,13 +126,14 @@ impl schemars::JsonSchema for ToolKind {
|
|||
.filter_map(|v| v.as_str().map(|s| format!("`{s}`")))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
schemars::json_schema!(
|
||||
{ "type" : "string", "description" :
|
||||
format!("Categorizes what a tool does at a high level. Open set — consumers must \
|
||||
schemars::json_schema!({
|
||||
"type": "string",
|
||||
"description": format!(
|
||||
"Categorizes what a tool does at a high level. Open set — consumers must \
|
||||
tolerate unknown values (Rust deserializes them to `other` via \
|
||||
`#[serde(other)]`). Known values: {known}."),
|
||||
}
|
||||
)
|
||||
`#[serde(other)]`). Known values: {known}."
|
||||
),
|
||||
})
|
||||
}
|
||||
}
|
||||
/// Canonical identity for a tool call, resolved from a tool's registered
|
||||
|
|
@ -313,7 +314,7 @@ mod tests {
|
|||
let meta = CanonicalToolMeta::new(
|
||||
"read_file",
|
||||
&identity(ToolKind::Read),
|
||||
Some(serde_json::json!({ "path" : "/a" })),
|
||||
Some(serde_json::json!({ "path": "/a" })),
|
||||
);
|
||||
let t = serde_json::to_value(&meta).unwrap();
|
||||
assert_eq!(t["version"], serde_json::json!(TOOL_META_VERSION));
|
||||
|
|
@ -367,7 +368,7 @@ mod tests {
|
|||
#[test]
|
||||
fn merge_into_nests_under_one_key_and_preserves_existing() {
|
||||
let meta = CanonicalToolMeta::new("run_terminal_cmd", &identity(ToolKind::Execute), None);
|
||||
let merged = meta.merge_into(Some(serde_json::json!({ "bash_mode" : true })));
|
||||
let merged = meta.merge_into(Some(serde_json::json!({"bash_mode": true})));
|
||||
let o = merged.as_object().unwrap();
|
||||
assert_eq!(o["bash_mode"], true, "existing meta must be preserved");
|
||||
let t = &o[TOOL_META_KEY];
|
||||
|
|
|
|||
|
|
@ -115,10 +115,12 @@ impl MediaGenOutput {
|
|||
let message = format!(
|
||||
"{action} and saved to {path}. Do not read or re-display it, and do not describe how it appears to the user."
|
||||
);
|
||||
serde_json::json!(
|
||||
{ "path" : path, "filename" : & self.filename, "session_folder" : & self
|
||||
.session_folder, "message" : message, }
|
||||
)
|
||||
serde_json::json!({
|
||||
"path": path,
|
||||
"filename": &self.filename,
|
||||
"session_folder": &self.session_folder,
|
||||
"message": message,
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
}
|
||||
|
|
@ -1413,8 +1415,7 @@ mod tests {
|
|||
to_json(ReadFileOutput::FileNotFound("Error: /tmp/x does not exist.".into()).into());
|
||||
assert_eq!(
|
||||
json,
|
||||
json!({ "type" : "ReadFile", "FileNotFound" :
|
||||
"Error: /tmp/x does not exist." })
|
||||
json!({"type": "ReadFile", "FileNotFound": "Error: /tmp/x does not exist."})
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
|
|
@ -1423,8 +1424,7 @@ mod tests {
|
|||
to_json(ReadFileOutput::IsADirectory("Error: /tmp is a directory.".into()).into());
|
||||
assert_eq!(
|
||||
json,
|
||||
json!({ "type" : "ReadFile", "IsADirectory" :
|
||||
"Error: /tmp is a directory." })
|
||||
json!({"type": "ReadFile", "IsADirectory": "Error: /tmp is a directory."})
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
|
|
@ -1434,8 +1434,7 @@ mod tests {
|
|||
);
|
||||
assert_eq!(
|
||||
json,
|
||||
json!({ "type" : "ReadFile", "PermissionDenied" :
|
||||
"Permission denied: /etc/shadow" })
|
||||
json!({"type": "ReadFile", "PermissionDenied": "Permission denied: /etc/shadow"})
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
|
|
@ -1448,9 +1447,7 @@ mod tests {
|
|||
);
|
||||
assert_eq!(
|
||||
json,
|
||||
json!({ "type" : "ReadFile", "FileTooLarge" :
|
||||
"File content (37044 tokens) exceeds maximum allowed tokens (25000 tokens)."
|
||||
})
|
||||
json!({"type": "ReadFile", "FileTooLarge": "File content (37044 tokens) exceeds maximum allowed tokens (25000 tokens)."})
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
|
|
@ -1458,7 +1455,7 @@ mod tests {
|
|||
let json = to_json(ReadFileOutput::FileReadError("Failed to read file".into()).into());
|
||||
assert_eq!(
|
||||
json,
|
||||
json!({ "type" : "ReadFile", "FileReadError" : "Failed to read file" })
|
||||
json!({"type": "ReadFile", "FileReadError": "Failed to read file"})
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
|
|
@ -1466,7 +1463,7 @@ mod tests {
|
|||
let json = to_json(ReadFileOutput::ImageSizeError("Image too large".into()).into());
|
||||
assert_eq!(
|
||||
json,
|
||||
json!({ "type" : "ReadFile", "ImageSizeError" : "Image too large" })
|
||||
json!({"type": "ReadFile", "ImageSizeError": "Image too large"})
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
|
|
@ -1474,20 +1471,20 @@ mod tests {
|
|||
let json = to_json(ListDirOutput::NotFound("does not exist".into()).into());
|
||||
assert_eq!(
|
||||
json,
|
||||
json!({ "type" : "ListDir", "NotFound" : "does not exist" })
|
||||
json!({"type": "ListDir", "NotFound": "does not exist"})
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn list_dir_is_a_file_json() {
|
||||
let json = to_json(ListDirOutput::IsAFile("is a file".into()).into());
|
||||
assert_eq!(json, json!({ "type" : "ListDir", "IsAFile" : "is a file" }));
|
||||
assert_eq!(json, json!({"type": "ListDir", "IsAFile": "is a file"}));
|
||||
}
|
||||
#[test]
|
||||
fn list_dir_not_a_directory_json() {
|
||||
let json = to_json(ListDirOutput::NotADirectory("is not a directory".into()).into());
|
||||
assert_eq!(
|
||||
json,
|
||||
json!({ "type" : "ListDir", "NotADirectory" : "is not a directory" })
|
||||
json!({"type": "ListDir", "NotADirectory": "is not a directory"})
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
|
|
@ -1495,20 +1492,20 @@ mod tests {
|
|||
let json = to_json(ListDirOutput::PermissionDenied("Permission denied".into()).into());
|
||||
assert_eq!(
|
||||
json,
|
||||
json!({ "type" : "ListDir", "PermissionDenied" : "Permission denied" })
|
||||
json!({"type": "ListDir", "PermissionDenied": "Permission denied"})
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn list_dir_generic_error_json() {
|
||||
let json = to_json(ListDirOutput::Error("Some error".into()).into());
|
||||
assert_eq!(json, json!({ "type" : "ListDir", "Error" : "Some error" }));
|
||||
assert_eq!(json, json!({"type": "ListDir", "Error": "Some error"}));
|
||||
}
|
||||
#[test]
|
||||
fn search_replace_file_not_found_json() {
|
||||
let json = to_json(SearchReplaceOutput::FileNotFound("not found".into()).into());
|
||||
assert_eq!(
|
||||
json,
|
||||
json!({ "type" : "SearchReplace", "FileNotFound" : "not found" })
|
||||
json!({"type": "SearchReplace", "FileNotFound": "not found"})
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
|
|
@ -1523,8 +1520,13 @@ mod tests {
|
|||
);
|
||||
assert_eq!(
|
||||
json,
|
||||
json!({ "type" : "SearchReplace", "NoMatchesFound" : { "message" :
|
||||
"no matches", "file_path" : "/project/src/main.c" } })
|
||||
json!({
|
||||
"type": "SearchReplace",
|
||||
"NoMatchesFound": {
|
||||
"message": "no matches",
|
||||
"file_path": "/project/src/main.c"
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
|
|
@ -1548,8 +1550,7 @@ mod tests {
|
|||
let json = to_json(SearchReplaceOutput::MultipleMatchesFound("3 matches".into()).into());
|
||||
assert_eq!(
|
||||
json,
|
||||
json!({ "type" : "SearchReplace", "MultipleMatchesFound" : "3 matches"
|
||||
})
|
||||
json!({"type": "SearchReplace", "MultipleMatchesFound": "3 matches"})
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
|
|
@ -1557,7 +1558,7 @@ mod tests {
|
|||
let json = to_json(SearchReplaceOutput::FileAlreadyExists("exists".into()).into());
|
||||
assert_eq!(
|
||||
json,
|
||||
json!({ "type" : "SearchReplace", "FileAlreadyExists" : "exists" })
|
||||
json!({"type": "SearchReplace", "FileAlreadyExists": "exists"})
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
|
|
@ -1565,7 +1566,7 @@ mod tests {
|
|||
let json = to_json(SearchReplaceOutput::InvalidInput("same strings".into()).into());
|
||||
assert_eq!(
|
||||
json,
|
||||
json!({ "type" : "SearchReplace", "InvalidInput" : "same strings" })
|
||||
json!({"type": "SearchReplace", "InvalidInput": "same strings"})
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
|
|
@ -1573,8 +1574,7 @@ mod tests {
|
|||
let json = to_json(SearchReplaceOutput::FilenameTooLong("name too long".into()).into());
|
||||
assert_eq!(
|
||||
json,
|
||||
json!({ "type" : "SearchReplace", "FilenameTooLong" : "name too long"
|
||||
})
|
||||
json!({"type": "SearchReplace", "FilenameTooLong": "name too long"})
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
|
|
@ -1602,8 +1602,10 @@ mod tests {
|
|||
);
|
||||
assert_eq!(
|
||||
json,
|
||||
json!({ "type" : "KillTask", "TaskNotFound" :
|
||||
"Task abc not found. No background tasks exist in this session." })
|
||||
json!({
|
||||
"type": "KillTask",
|
||||
"TaskNotFound": "Task abc not found. No background tasks exist in this session."
|
||||
})
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
|
|
@ -1612,8 +1614,7 @@ mod tests {
|
|||
let serialized = serde_json::to_value(&original).unwrap();
|
||||
let deserialized: KillTaskOutput = serde_json::from_value(serialized).unwrap();
|
||||
assert!(
|
||||
matches!(deserialized, KillTaskOutput::TaskNotFound(ref msg) if msg ==
|
||||
"not found")
|
||||
matches!(deserialized, KillTaskOutput::TaskNotFound(ref msg) if msg == "not found")
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
|
|
@ -1722,8 +1723,10 @@ mod tests {
|
|||
);
|
||||
assert_eq!(
|
||||
json,
|
||||
json!({ "type" : "TaskOutput", "TaskNotFound" :
|
||||
"Task xyz not found. Known task IDs: [task-1, task-2]" })
|
||||
json!({
|
||||
"type": "TaskOutput",
|
||||
"TaskNotFound": "Task xyz not found. Known task IDs: [task-1, task-2]"
|
||||
})
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
|
|
@ -1732,8 +1735,7 @@ mod tests {
|
|||
let serialized = serde_json::to_value(&original).unwrap();
|
||||
let deserialized: TaskOutputOutput = serde_json::from_value(serialized).unwrap();
|
||||
assert!(
|
||||
matches!(deserialized, TaskOutputOutput::TaskNotFound(ref msg) if msg ==
|
||||
"not found")
|
||||
matches!(deserialized, TaskOutputOutput::TaskNotFound(ref msg) if msg == "not found")
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
|
|
@ -1774,8 +1776,9 @@ mod tests {
|
|||
);
|
||||
assert_eq!(
|
||||
json,
|
||||
json!({ "type" : "Todo", "DuplicateId" :
|
||||
"Duplicate todo ID in request: \"dup\". Each todo item must have a unique ID."
|
||||
json!({
|
||||
"type": "Todo",
|
||||
"DuplicateId": "Duplicate todo ID in request: \"dup\". Each todo item must have a unique ID."
|
||||
})
|
||||
);
|
||||
}
|
||||
|
|
@ -1784,10 +1787,7 @@ mod tests {
|
|||
let original = TodoWriteOutput::DuplicateId("dup id".into());
|
||||
let serialized = serde_json::to_value(&original).unwrap();
|
||||
let deserialized: TodoWriteOutput = serde_json::from_value(serialized).unwrap();
|
||||
assert!(
|
||||
matches!(deserialized, TodoWriteOutput::DuplicateId(ref msg) if msg ==
|
||||
"dup id")
|
||||
);
|
||||
assert!(matches!(deserialized, TodoWriteOutput::DuplicateId(ref msg) if msg == "dup id"));
|
||||
}
|
||||
#[test]
|
||||
fn todo_write_success_round_trip() {
|
||||
|
|
@ -2063,10 +2063,12 @@ mod tests {
|
|||
}
|
||||
#[test]
|
||||
fn enter_plan_mode_output_serde_defaults_tool_hints_when_absent() {
|
||||
let json = json!(
|
||||
{ "Entered" : { "message" : "Entered plan mode.", "plan_file_path" :
|
||||
"/tmp/plan.md" } }
|
||||
);
|
||||
let json = json!({
|
||||
"Entered": {
|
||||
"message": "Entered plan mode.",
|
||||
"plan_file_path": "/tmp/plan.md"
|
||||
}
|
||||
});
|
||||
let deserialized: EnterPlanModeOutput = serde_json::from_value(json).unwrap();
|
||||
match deserialized {
|
||||
EnterPlanModeOutput::Entered {
|
||||
|
|
@ -2115,10 +2117,12 @@ mod tests {
|
|||
}
|
||||
#[test]
|
||||
fn enter_plan_mode_absent_seed_field_prompt_is_missing() {
|
||||
let json = json!(
|
||||
{ "Entered" : { "message" : "Entered plan mode.", "plan_file_path" :
|
||||
"/tmp/plan.md" } }
|
||||
);
|
||||
let json = json!({
|
||||
"Entered": {
|
||||
"message": "Entered plan mode.",
|
||||
"plan_file_path": "/tmp/plan.md"
|
||||
}
|
||||
});
|
||||
let deserialized: EnterPlanModeOutput = serde_json::from_value(json).unwrap();
|
||||
let prompt = ToolOutput::EnterPlanMode(deserialized).to_prompt_format();
|
||||
assert!(
|
||||
|
|
@ -2170,7 +2174,7 @@ mod tests {
|
|||
let json = serde_json::to_value(&output).unwrap();
|
||||
assert_eq!(
|
||||
json["Entered"]["plan_file_seed"],
|
||||
json!({ "missing" : "not_a_file" })
|
||||
json!({ "missing": "not_a_file" })
|
||||
);
|
||||
let back: EnterPlanModeOutput = serde_json::from_value(json).unwrap();
|
||||
let EnterPlanModeOutput::Entered { plan_file_seed, .. } = back;
|
||||
|
|
|
|||
|
|
@ -1109,14 +1109,12 @@ mod tests {
|
|||
let mut state_map = HashMap::new();
|
||||
state_map.insert(
|
||||
"grok_build.ReadFile".to_string(),
|
||||
serde_json::json!({ "files_read" : ["loaded.rs"] }),
|
||||
serde_json::json!({"files_read": ["loaded.rs"]}),
|
||||
);
|
||||
let mut params_map = HashMap::new();
|
||||
params_map.insert(
|
||||
"grok_build.Edit".to_string(),
|
||||
serde_json::json!(
|
||||
{ "skip_read_before_edit" : true, "max_file_size" : 512 }
|
||||
),
|
||||
serde_json::json!({"skip_read_before_edit": true, "max_file_size": 512}),
|
||||
);
|
||||
let mut data = HashMap::new();
|
||||
data.insert("state".to_string(), state_map);
|
||||
|
|
@ -1135,11 +1133,11 @@ mod tests {
|
|||
let mut state_map = HashMap::new();
|
||||
state_map.insert(
|
||||
"unknown.Type".to_string(),
|
||||
serde_json::json!({ "foo" : "bar" }),
|
||||
serde_json::json!({"foo": "bar"}),
|
||||
);
|
||||
state_map.insert(
|
||||
"grok_build.ReadFile".to_string(),
|
||||
serde_json::json!({ "files_read" : ["ok.rs"] }),
|
||||
serde_json::json!({"files_read": ["ok.rs"]}),
|
||||
);
|
||||
let mut data = HashMap::new();
|
||||
data.insert("state".to_string(), state_map);
|
||||
|
|
@ -1176,7 +1174,7 @@ mod tests {
|
|||
let ok = res.set_json(
|
||||
"params",
|
||||
"grok_build.Edit",
|
||||
serde_json::json!({ "skip_read_before_edit" : true }),
|
||||
serde_json::json!({"skip_read_before_edit": true}),
|
||||
);
|
||||
assert!(ok);
|
||||
let config = res.get::<Params<EditConfig>>().unwrap();
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ impl schemars::JsonSchema for GrokIntegerSchema {
|
|||
"grok_integer_schema".into()
|
||||
}
|
||||
fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
|
||||
schemars::json_schema!({ "type" : "integer" })
|
||||
schemars::json_schema!({ "type": "integer" })
|
||||
}
|
||||
}
|
||||
/// Largest whole value exactly representable as `f64` (2^53). JSON floats above this
|
||||
|
|
|
|||
|
|
@ -174,9 +174,9 @@ mod tests {
|
|||
before_context: None,
|
||||
after_context: None,
|
||||
context: None,
|
||||
case_insensitive: None,
|
||||
case_insensitive: false,
|
||||
head_limit: None,
|
||||
multiline: None,
|
||||
multiline: false,
|
||||
r#type: None,
|
||||
})
|
||||
.try_into();
|
||||
|
|
@ -195,7 +195,7 @@ mod tests {
|
|||
}
|
||||
#[test]
|
||||
fn dynamic_input_holds_arbitrary_json() {
|
||||
let input = ToolInput::Dynamic(serde_json::json!({ "custom" : "data" }));
|
||||
let input = ToolInput::Dynamic(serde_json::json!({"custom": "data"}));
|
||||
match input {
|
||||
ToolInput::Dynamic(v) => {
|
||||
assert_eq!(v["custom"], "data");
|
||||
|
|
|
|||
Loading…
Reference in a new issue