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());
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue