diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index 746679af39..ecbe3b49d2 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -698,6 +698,7 @@ "chacha20_0.9.1": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"name\":\"cipher\",\"req\":\"^0.4.4\"},{\"features\":[\"dev\"],\"kind\":\"dev\",\"name\":\"cipher\",\"req\":\"^0.4.4\"},{\"name\":\"cpufeatures\",\"req\":\"^0.2\",\"target\":\"cfg(any(target_arch = \\\"x86_64\\\", target_arch = \\\"x86\\\"))\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.3.3\"}],\"features\":{\"std\":[\"cipher/std\"],\"zeroize\":[\"cipher/zeroize\"]}}", "chacha20poly1305_0.10.1": "{\"dependencies\":[{\"default_features\":false,\"name\":\"aead\",\"req\":\"^0.5\"},{\"default_features\":false,\"features\":[\"dev\"],\"kind\":\"dev\",\"name\":\"aead\",\"req\":\"^0.5\"},{\"features\":[\"zeroize\"],\"name\":\"chacha20\",\"req\":\"^0.9\"},{\"name\":\"cipher\",\"req\":\"^0.4\"},{\"name\":\"poly1305\",\"req\":\"^0.8\"},{\"default_features\":false,\"name\":\"zeroize\",\"req\":\"^1.5\"}],\"features\":{\"alloc\":[\"aead/alloc\"],\"default\":[\"alloc\",\"getrandom\"],\"getrandom\":[\"aead/getrandom\",\"rand_core\"],\"heapless\":[\"aead/heapless\"],\"rand_core\":[\"aead/rand_core\"],\"reduced-round\":[],\"std\":[\"aead/std\",\"alloc\"],\"stream\":[\"aead/stream\"]}}", "chardetng_0.1.17": "{\"dependencies\":[{\"name\":\"arrayvec\",\"optional\":true,\"req\":\"^0.5.1\"},{\"name\":\"cfg-if\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"detone\",\"req\":\"^1.0.0\"},{\"default_features\":false,\"name\":\"encoding_rs\",\"req\":\"^0.8.29\"},{\"default_features\":false,\"name\":\"memchr\",\"req\":\"^2.2.0\"},{\"name\":\"rayon\",\"optional\":true,\"req\":\"^1.3.0\"}],\"features\":{\"multithreading\":[\"rayon\",\"arrayvec\"],\"testing-only-no-semver-guarantees-do-not-use\":[]}}", + "chrono-tz_0.10.4": "{\"dependencies\":[{\"features\":[\"derive\"],\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.2\"},{\"default_features\":false,\"name\":\"chrono\",\"req\":\"^0.4.25\"},{\"default_features\":false,\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"chrono\",\"req\":\"^0.4\"},{\"kind\":\"build\",\"name\":\"chrono-tz-build\",\"optional\":true,\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"chrono-tz-build\",\"req\":\"^0.5\"},{\"default_features\":false,\"name\":\"phf\",\"req\":\"^0.12\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.99\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"tzfile\",\"req\":\"^0.1\"},{\"default_features\":false,\"name\":\"uncased\",\"optional\":true,\"req\":\"^0.9\"}],\"features\":{\"case-insensitive\":[\"dep:uncased\",\"chrono-tz-build\",\"chrono-tz-build/case-insensitive\",\"phf/uncased\"],\"default\":[\"std\"],\"filter-by-regex\":[\"chrono-tz-build\",\"chrono-tz-build/filter-by-regex\"],\"serde\":[\"dep:serde\"],\"std\":[]}}", "chrono_0.4.43": "{\"dependencies\":[{\"features\":[\"derive\"],\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1.3.0\"},{\"name\":\"defmt\",\"optional\":true,\"req\":\"^1.0.1\"},{\"features\":[\"fallback\"],\"name\":\"iana-time-zone\",\"optional\":true,\"req\":\"^0.1.45\",\"target\":\"cfg(unix)\"},{\"name\":\"js-sys\",\"optional\":true,\"req\":\"^0.3\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", not(any(target_os = \\\"emscripten\\\", target_os = \\\"wasi\\\"))))\"},{\"default_features\":false,\"name\":\"num-traits\",\"req\":\"^0.2\"},{\"name\":\"pure-rust-locales\",\"optional\":true,\"req\":\"^0.8.2\"},{\"default_features\":false,\"name\":\"rkyv\",\"optional\":true,\"req\":\"^0.7.43\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.99\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"similar-asserts\",\"req\":\"^1.6.1\"},{\"name\":\"wasm-bindgen\",\"optional\":true,\"req\":\"^0.2\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", not(any(target_os = \\\"emscripten\\\", target_os = \\\"wasi\\\"))))\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", not(any(target_os = \\\"emscripten\\\", target_os = \\\"wasi\\\"))))\"},{\"kind\":\"dev\",\"name\":\"windows-bindgen\",\"req\":\"^0.66\"},{\"name\":\"windows-link\",\"optional\":true,\"req\":\"^0.2\",\"target\":\"cfg(windows)\"}],\"features\":{\"__internal_bench\":[],\"alloc\":[],\"clock\":[\"winapi\",\"iana-time-zone\",\"now\"],\"core-error\":[],\"default\":[\"clock\",\"std\",\"oldtime\",\"wasmbind\"],\"defmt\":[\"dep:defmt\",\"pure-rust-locales?/defmt\"],\"libc\":[],\"now\":[\"std\"],\"oldtime\":[],\"rkyv\":[\"dep:rkyv\",\"rkyv/size_32\"],\"rkyv-16\":[\"dep:rkyv\",\"rkyv?/size_16\"],\"rkyv-32\":[\"dep:rkyv\",\"rkyv?/size_32\"],\"rkyv-64\":[\"dep:rkyv\",\"rkyv?/size_64\"],\"rkyv-validation\":[\"rkyv?/validation\"],\"std\":[\"alloc\"],\"unstable-locales\":[\"pure-rust-locales\"],\"wasmbind\":[\"wasm-bindgen\",\"js-sys\"],\"winapi\":[\"windows-link\"]}}", "chunked_transfer_1.5.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3\"}],\"features\":{}}", "cipher_0.4.4": "{\"dependencies\":[{\"name\":\"blobby\",\"optional\":true,\"req\":\"^0.3\"},{\"name\":\"crypto-common\",\"req\":\"^0.1.6\"},{\"name\":\"inout\",\"req\":\"^0.1\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1.5\"}],\"features\":{\"alloc\":[],\"block-padding\":[\"inout/block-padding\"],\"dev\":[\"blobby\"],\"rand_core\":[\"crypto-common/rand_core\"],\"std\":[\"alloc\",\"crypto-common/std\",\"inout/std\"]}}", @@ -1138,7 +1139,9 @@ "percent-encoding_2.3.2": "{\"dependencies\":[],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"std\":[\"alloc\"]}}", "petgraph_0.6.5": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"ahash\",\"req\":\"^0.7.2\"},{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1.3.3\"},{\"kind\":\"dev\",\"name\":\"defmac\",\"req\":\"^0.2.1\"},{\"default_features\":false,\"name\":\"fixedbitset\",\"req\":\"^0.4.0\"},{\"kind\":\"dev\",\"name\":\"fxhash\",\"req\":\"^0.2.1\"},{\"name\":\"indexmap\",\"req\":\"^2.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"itertools\",\"req\":\"^0.12.1\"},{\"kind\":\"dev\",\"name\":\"odds\",\"req\":\"^0.4.0\"},{\"default_features\":false,\"name\":\"quickcheck\",\"optional\":true,\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.5.5\"},{\"name\":\"rayon\",\"optional\":true,\"req\":\"^1.5.3\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"serde_derive\",\"optional\":true,\"req\":\"^1.0\"}],\"features\":{\"all\":[\"unstable\",\"quickcheck\",\"matrix_graph\",\"stable_graph\",\"graphmap\",\"rayon\"],\"default\":[\"graphmap\",\"stable_graph\",\"matrix_graph\"],\"generate\":[],\"graphmap\":[],\"matrix_graph\":[],\"rayon\":[\"dep:rayon\",\"indexmap/rayon\"],\"serde-1\":[\"serde\",\"serde_derive\"],\"stable_graph\":[],\"unstable\":[\"generate\"]}}", "petgraph_0.8.3": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"ahash\",\"req\":\"^0.7.2\"},{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1.3.3\"},{\"kind\":\"dev\",\"name\":\"defmac\",\"req\":\"^0.2.1\"},{\"name\":\"dot-parser\",\"optional\":true,\"req\":\"^0.5.1\"},{\"name\":\"dot-parser-macros\",\"optional\":true,\"req\":\"^0.5.1\"},{\"default_features\":false,\"name\":\"fixedbitset\",\"req\":\"^0.5.7\"},{\"kind\":\"dev\",\"name\":\"fxhash\",\"req\":\"^0.2.1\"},{\"default_features\":false,\"features\":[\"default-hasher\",\"inline-more\"],\"name\":\"hashbrown\",\"req\":\"^0.15.0\"},{\"default_features\":false,\"name\":\"indexmap\",\"req\":\"^2.5.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"itertools\",\"req\":\"^0.12.1\"},{\"kind\":\"dev\",\"name\":\"odds\",\"req\":\"^0.4.0\"},{\"default_features\":false,\"name\":\"quickcheck\",\"optional\":true,\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.5.5\"},{\"name\":\"rayon\",\"optional\":true,\"req\":\"^1.5.3\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"serde_derive\",\"optional\":true,\"req\":\"^1.0\"}],\"features\":{\"all\":[\"unstable\",\"quickcheck\",\"matrix_graph\",\"stable_graph\",\"graphmap\",\"rayon\",\"dot_parser\"],\"default\":[\"std\",\"graphmap\",\"stable_graph\",\"matrix_graph\"],\"dot_parser\":[\"std\",\"dep:dot-parser\",\"dep:dot-parser-macros\"],\"generate\":[],\"graphmap\":[],\"matrix_graph\":[],\"quickcheck\":[\"std\",\"dep:quickcheck\",\"graphmap\",\"stable_graph\"],\"rayon\":[\"std\",\"dep:rayon\",\"indexmap/rayon\",\"hashbrown/rayon\"],\"serde-1\":[\"serde\",\"serde_derive\"],\"stable_graph\":[\"serde?/alloc\"],\"std\":[\"indexmap/std\"],\"unstable\":[\"generate\"]}}", + "phf_0.12.1": "{\"dependencies\":[{\"name\":\"phf_macros\",\"optional\":true,\"req\":\"^0.12.0\"},{\"default_features\":false,\"name\":\"phf_shared\",\"req\":\"^0.12.0\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"}],\"features\":{\"default\":[\"std\"],\"macros\":[\"phf_macros\"],\"std\":[\"phf_shared/std\",\"serde?/std\"],\"uncased\":[\"phf_macros?/uncased\",\"phf_shared/uncased\"],\"unicase\":[\"phf_macros?/unicase\",\"phf_shared/unicase\"]}}", "phf_shared_0.11.3": "{\"dependencies\":[{\"name\":\"siphasher\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"uncased\",\"optional\":true,\"req\":\"^0.9.9\"},{\"name\":\"unicase\",\"optional\":true,\"req\":\"^2.4.0\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", + "phf_shared_0.12.1": "{\"dependencies\":[{\"name\":\"siphasher\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"uncased\",\"optional\":true,\"req\":\"^0.9.9\"},{\"name\":\"unicase\",\"optional\":true,\"req\":\"^2.4.0\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", "pin-project-internal_1.1.10": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1.0.60\"},{\"name\":\"quote\",\"req\":\"^1.0.25\"},{\"default_features\":false,\"features\":[\"parsing\",\"printing\",\"clone-impls\",\"proc-macro\",\"full\",\"visit-mut\"],\"name\":\"syn\",\"req\":\"^2.0.1\"}],\"features\":{}}", "pin-project-lite_0.2.16": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1\"}],\"features\":{}}", "pin-project-lite_0.2.17": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1\"}],\"features\":{}}", @@ -1236,6 +1239,7 @@ "ring_0.17.14": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.2.8\"},{\"default_features\":false,\"name\":\"cfg-if\",\"req\":\"^1.0.0\"},{\"name\":\"getrandom\",\"req\":\"^0.2.10\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.148\",\"target\":\"cfg(all(any(all(target_arch = \\\"aarch64\\\", target_endian = \\\"little\\\"), all(target_arch = \\\"arm\\\", target_endian = \\\"little\\\")), any(target_os = \\\"android\\\", target_os = \\\"linux\\\")))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.155\",\"target\":\"cfg(all(all(target_arch = \\\"aarch64\\\", target_endian = \\\"little\\\"), target_vendor = \\\"apple\\\", any(target_os = \\\"ios\\\", target_os = \\\"macos\\\", target_os = \\\"tvos\\\", target_os = \\\"visionos\\\", target_os = \\\"watchos\\\")))\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2.148\",\"target\":\"cfg(any(unix, windows, target_os = \\\"wasi\\\"))\"},{\"name\":\"untrusted\",\"req\":\"^0.9\"},{\"default_features\":false,\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3.37\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", target_os = \\\"unknown\\\"))\"},{\"features\":[\"Win32_Foundation\",\"Win32_System_Threading\"],\"name\":\"windows-sys\",\"req\":\"^0.52\",\"target\":\"cfg(all(all(target_arch = \\\"aarch64\\\", target_endian = \\\"little\\\"), target_os = \\\"windows\\\"))\"}],\"features\":{\"alloc\":[],\"default\":[\"alloc\",\"dev_urandom_fallback\"],\"dev_urandom_fallback\":[],\"less-safe-getrandom-custom-or-rdrand\":[],\"less-safe-getrandom-espidf\":[],\"slow_tests\":[],\"std\":[\"alloc\"],\"test_logging\":[],\"unstable-testing-arm-no-hw\":[],\"unstable-testing-arm-no-neon\":[],\"wasm32_unknown_unknown_js\":[\"getrandom/js\"]}}", "rmcp-macros_0.15.0": "{\"dependencies\":[{\"name\":\"darling\",\"req\":\"^0.23\"},{\"name\":\"proc-macro2\",\"req\":\"^1\"},{\"name\":\"quote\",\"req\":\"^1\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"features\":[\"full\"],\"name\":\"syn\",\"req\":\"^2\"}],\"features\":{}}", "rmcp_0.15.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0\"},{\"name\":\"async-trait\",\"req\":\"^0.1.89\"},{\"kind\":\"dev\",\"name\":\"async-trait\",\"req\":\"^0.1\"},{\"name\":\"axum\",\"optional\":true,\"req\":\"^0.8\"},{\"name\":\"base64\",\"optional\":true,\"req\":\"^0.22\"},{\"name\":\"bytes\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"serde\",\"clock\",\"std\",\"oldtime\"],\"name\":\"chrono\",\"req\":\"^0.4.38\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\"))\"},{\"features\":[\"serde\"],\"name\":\"chrono\",\"req\":\"^0.4.38\",\"target\":\"cfg(not(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\")))\"},{\"name\":\"futures\",\"req\":\"^0.3\"},{\"name\":\"http\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"http-body\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"http-body-util\",\"optional\":true,\"req\":\"^0.1\"},{\"default_features\":false,\"features\":[\"reqwest\"],\"name\":\"oauth2\",\"optional\":true,\"req\":\"^5.0\"},{\"name\":\"pastey\",\"optional\":true,\"req\":\"^0.2.0\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2\"},{\"features\":[\"tokio1\"],\"name\":\"process-wrap\",\"optional\":true,\"req\":\"^9.0\"},{\"name\":\"rand\",\"optional\":true,\"req\":\"^0.9\"},{\"default_features\":false,\"features\":[\"json\",\"stream\"],\"name\":\"reqwest\",\"optional\":true,\"req\":\"^0.12\"},{\"name\":\"rmcp-macros\",\"optional\":true,\"req\":\"^0.15.0\"},{\"features\":[\"chrono04\"],\"name\":\"schemars\",\"optional\":true,\"req\":\"^1.0\"},{\"features\":[\"chrono04\"],\"kind\":\"dev\",\"name\":\"schemars\",\"req\":\"^1.1.0\"},{\"features\":[\"derive\",\"rc\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"sse-stream\",\"optional\":true,\"req\":\"^0.2\"},{\"name\":\"thiserror\",\"req\":\"^2\"},{\"features\":[\"sync\",\"macros\",\"rt\",\"time\"],\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"name\":\"tokio-stream\",\"optional\":true,\"req\":\"^0.1\"},{\"name\":\"tokio-util\",\"req\":\"^0.7\"},{\"name\":\"tower-service\",\"optional\":true,\"req\":\"^0.3\"},{\"name\":\"tracing\",\"req\":\"^0.1\"},{\"features\":[\"env-filter\",\"std\",\"fmt\"],\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3\"},{\"name\":\"url\",\"optional\":true,\"req\":\"^2.4\"},{\"features\":[\"v4\"],\"name\":\"uuid\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"__reqwest\":[\"dep:reqwest\"],\"auth\":[\"dep:oauth2\",\"__reqwest\",\"dep:url\"],\"client\":[\"dep:tokio-stream\"],\"client-side-sse\":[\"dep:sse-stream\",\"dep:http\"],\"default\":[\"base64\",\"macros\",\"server\"],\"elicitation\":[\"dep:url\"],\"macros\":[\"dep:rmcp-macros\",\"dep:pastey\"],\"reqwest\":[\"__reqwest\",\"reqwest?/rustls-tls\"],\"reqwest-native-tls\":[\"__reqwest\",\"reqwest?/native-tls\"],\"reqwest-tls-no-provider\":[\"__reqwest\",\"reqwest?/rustls-tls-no-provider\"],\"schemars\":[\"dep:schemars\"],\"server\":[\"transport-async-rw\",\"dep:schemars\",\"dep:pastey\"],\"server-side-http\":[\"uuid\",\"dep:rand\",\"dep:tokio-stream\",\"dep:http\",\"dep:http-body\",\"dep:http-body-util\",\"dep:bytes\",\"dep:sse-stream\",\"dep:axum\",\"tower\"],\"tower\":[\"dep:tower-service\"],\"transport-async-rw\":[\"tokio/io-util\",\"tokio-util/codec\"],\"transport-child-process\":[\"transport-async-rw\",\"tokio/process\",\"dep:process-wrap\"],\"transport-io\":[\"transport-async-rw\",\"tokio/io-std\"],\"transport-streamable-http-client\":[\"client-side-sse\",\"transport-worker\"],\"transport-streamable-http-client-reqwest\":[\"transport-streamable-http-client\",\"__reqwest\"],\"transport-streamable-http-server\":[\"transport-streamable-http-server-session\",\"server-side-http\",\"transport-worker\"],\"transport-streamable-http-server-session\":[\"transport-async-rw\",\"dep:tokio-stream\"],\"transport-worker\":[\"dep:tokio-stream\"]}}", + "rrule_0.14.0": "{\"dependencies\":[{\"name\":\"chrono\",\"req\":\"^0.4.39\"},{\"name\":\"chrono-tz\",\"req\":\"^0.10.1\"},{\"features\":[\"derive\"],\"name\":\"clap\",\"optional\":true,\"req\":\"^4.5.26\"},{\"name\":\"log\",\"req\":\"^0.4.25\"},{\"default_features\":false,\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"orig_serde\",\"package\":\"serde\",\"req\":\"^1.0.217\"},{\"default_features\":false,\"features\":[\"perf\",\"std\"],\"name\":\"regex\",\"req\":\"^1.11.1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.135\"},{\"name\":\"serde_with\",\"optional\":true,\"req\":\"^3.12.0\"},{\"name\":\"thiserror\",\"req\":\"^2.0.11\"}],\"features\":{\"by-easter\":[],\"cli-tool\":[\"clap\"],\"default\":[],\"exrule\":[],\"serde\":[\"serde_with\",\"chrono/serde\",\"chrono-tz/serde\"]}}", "rsa_0.9.10": "{\"dependencies\":[{\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"base64ct\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"const-oid\",\"req\":\"^0.9\"},{\"default_features\":false,\"features\":[\"alloc\",\"oid\"],\"name\":\"digest\",\"req\":\"^0.10.5\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.4.1\"},{\"default_features\":false,\"features\":[\"i128\",\"prime\",\"zeroize\"],\"name\":\"num-bigint\",\"package\":\"num-bigint-dig\",\"req\":\"^0.8.6\"},{\"default_features\":false,\"name\":\"num-integer\",\"req\":\"^0.1.39\"},{\"default_features\":false,\"features\":[\"libm\"],\"name\":\"num-traits\",\"req\":\"^0.2.9\"},{\"default_features\":false,\"features\":[\"alloc\",\"pkcs8\"],\"name\":\"pkcs1\",\"req\":\"^0.7.5\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"pkcs8\",\"req\":\"^0.10.2\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"rand_chacha\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"rand_core\",\"req\":\"^0.6.4\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"rand_core\",\"req\":\"^0.6\"},{\"kind\":\"dev\",\"name\":\"rand_xorshift\",\"req\":\"^0.3\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.184\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0.89\"},{\"default_features\":false,\"features\":[\"oid\"],\"name\":\"sha1\",\"optional\":true,\"req\":\"^0.10.5\"},{\"default_features\":false,\"features\":[\"oid\"],\"kind\":\"dev\",\"name\":\"sha1\",\"req\":\"^0.10.5\"},{\"default_features\":false,\"features\":[\"oid\"],\"name\":\"sha2\",\"optional\":true,\"req\":\"^0.10.6\"},{\"default_features\":false,\"features\":[\"oid\"],\"kind\":\"dev\",\"name\":\"sha2\",\"req\":\"^0.10.6\"},{\"default_features\":false,\"features\":[\"oid\"],\"kind\":\"dev\",\"name\":\"sha3\",\"req\":\"^0.10.7\"},{\"default_features\":false,\"features\":[\"alloc\",\"digest\",\"rand_core\"],\"name\":\"signature\",\"req\":\">2.0, <2.3\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"spki\",\"req\":\"^0.7.3\"},{\"default_features\":false,\"name\":\"subtle\",\"req\":\"^2.1.1\"},{\"features\":[\"alloc\"],\"name\":\"zeroize\",\"req\":\"^1.5\"}],\"features\":{\"default\":[\"std\",\"pem\",\"u64_digit\"],\"getrandom\":[\"rand_core/getrandom\"],\"hazmat\":[],\"nightly\":[\"num-bigint/nightly\"],\"pem\":[\"pkcs1/pem\",\"pkcs8/pem\"],\"pkcs5\":[\"pkcs8/encryption\"],\"serde\":[\"dep:serde\",\"num-bigint/serde\"],\"std\":[\"digest/std\",\"pkcs1/std\",\"pkcs8/std\",\"rand_core/std\",\"signature/std\"],\"u64_digit\":[\"num-bigint/u64_digit\"]}}", "rust-embed-impl_8.11.0": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1\"},{\"name\":\"quote\",\"req\":\"^1\"},{\"name\":\"rust-embed-utils\",\"req\":\"^8.11.0\"},{\"name\":\"shellexpand\",\"optional\":true,\"req\":\"^3\"},{\"default_features\":false,\"features\":[\"derive\",\"parsing\",\"proc-macro\",\"printing\"],\"name\":\"syn\",\"req\":\"^2\"},{\"name\":\"walkdir\",\"req\":\"^2.3.1\"}],\"features\":{\"compression\":[],\"debug-embed\":[],\"deterministic-timestamps\":[],\"include-exclude\":[\"rust-embed-utils/include-exclude\"],\"interpolate-folder-path\":[\"shellexpand\"],\"mime-guess\":[\"rust-embed-utils/mime-guess\"]}}", "rust-embed-utils_8.11.0": "{\"dependencies\":[{\"name\":\"globset\",\"optional\":true,\"req\":\"^0.4.8\"},{\"name\":\"mime_guess\",\"optional\":true,\"req\":\"^2.0.4\"},{\"name\":\"sha2\",\"req\":\"^0.10.5\"},{\"name\":\"walkdir\",\"req\":\"^2.3.1\"}],\"features\":{\"debug-embed\":[],\"include-exclude\":[\"globset\"],\"mime-guess\":[\"mime_guess\"]}}", diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 976485c5c5..9ea146dba4 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1232,6 +1232,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "chrono-tz" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" +dependencies = [ + "chrono", + "phf", +] + [[package]] name = "chunked_transfer" version = "1.5.0" @@ -1849,6 +1859,7 @@ dependencies = [ "base64 0.22.1", "bm25", "chrono", + "chrono-tz", "clap", "codex-analytics", "codex-api", @@ -1926,6 +1937,7 @@ dependencies = [ "regex-lite", "reqwest", "rmcp", + "rrule", "schemars 0.8.22", "serde", "serde_json", @@ -7188,6 +7200,15 @@ dependencies = [ "indexmap 2.13.0", ] +[[package]] +name = "phf" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" +dependencies = [ + "phf_shared 0.12.1", +] + [[package]] name = "phf_shared" version = "0.11.3" @@ -7197,6 +7218,15 @@ dependencies = [ "siphasher", ] +[[package]] +name = "phf_shared" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project" version = "1.1.10" @@ -8347,6 +8377,19 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "rrule" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "720acfb4980b9d8a6a430f6d7a11933e701ebbeba5eee39cc9d8c5f932aaff74" +dependencies = [ + "chrono", + "chrono-tz", + "log", + "regex", + "thiserror 2.0.18", +] + [[package]] name = "rsa" version = "0.9.10" @@ -9693,7 +9736,7 @@ checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" dependencies = [ "new_debug_unreachable", "parking_lot", - "phf_shared", + "phf_shared 0.11.3", "precomputed-hash", ] diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 16329276cd..9ef6984052 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -208,6 +208,7 @@ bm25 = "2.3.2" bytes = "1.10.1" chardetng = "0.1.17" chrono = "0.4.43" +chrono-tz = "0.10.4" clap = "4" clap_complete = "4" color-eyre = "0.6.3" @@ -275,6 +276,7 @@ ratatui-macros = "0.6.0" regex = "1.12.3" regex-lite = "0.1.8" reqwest = "0.12" +rrule = "0.14.0" rmcp = { version = "0.15.0", default-features = false } runfiles = { git = "https://github.com/dzbarsky/rules_rust", rev = "b56cbaa8465e74127f1ea216f813cd377295ad81" } rustls = { version = "0.23", default-features = false, features = [ diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index d4e5f1d39f..3fe322a21c 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -12,6 +12,63 @@ ], "type": "string" }, + "AlarmTrigger": { + "oneOf": [ + { + "properties": { + "kind": { + "enum": [ + "delay" + ], + "type": "string" + }, + "repeat": { + "type": [ + "boolean", + "null" + ] + }, + "seconds": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "kind", + "seconds" + ], + "type": "object" + }, + { + "properties": { + "dtstart": { + "type": [ + "string", + "null" + ] + }, + "kind": { + "enum": [ + "schedule" + ], + "type": "string" + }, + "rrule": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind" + ], + "title": "KindAlarmTrigger", + "type": "object" + } + ] + }, "ApprovalsReviewer": { "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `guardian_subagent` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request.", "enum": [ diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 4ee98a03d1..a2d82b3bcf 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -97,6 +97,63 @@ ], "type": "string" }, + "AlarmTrigger": { + "oneOf": [ + { + "properties": { + "kind": { + "enum": [ + "delay" + ], + "type": "string" + }, + "repeat": { + "type": [ + "boolean", + "null" + ] + }, + "seconds": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "kind", + "seconds" + ], + "type": "object" + }, + { + "properties": { + "dtstart": { + "type": [ + "string", + "null" + ] + }, + "kind": { + "enum": [ + "schedule" + ], + "type": "string" + }, + "rrule": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind" + ], + "title": "KindAlarmTrigger", + "type": "object" + } + ] + }, "AppBranding": { "description": "EXPERIMENTAL - app metadata returned by app-list APIs.", "properties": { @@ -2532,9 +2589,6 @@ "format": "int64", "type": "integer" }, - "cronExpression": { - "type": "string" - }, "delivery": { "$ref": "#/definitions/AlarmDelivery" }, @@ -2558,17 +2612,16 @@ "prompt": { "type": "string" }, - "runOnce": { - "type": "boolean" + "trigger": { + "$ref": "#/definitions/AlarmTrigger" } }, "required": [ "createdAt", - "cronExpression", "delivery", "id", "prompt", - "runOnce" + "trigger" ], "type": "object" }, diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index da38bbe178..7370539ebb 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -4966,6 +4966,63 @@ ], "type": "string" }, + "AlarmTrigger": { + "oneOf": [ + { + "properties": { + "kind": { + "enum": [ + "delay" + ], + "type": "string" + }, + "repeat": { + "type": [ + "boolean", + "null" + ] + }, + "seconds": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "kind", + "seconds" + ], + "type": "object" + }, + { + "properties": { + "dtstart": { + "type": [ + "string", + "null" + ] + }, + "kind": { + "enum": [ + "schedule" + ], + "type": "string" + }, + "rrule": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind" + ], + "title": "KindAlarmTrigger", + "type": "object" + } + ] + }, "AnalyticsConfig": { "additionalProperties": true, "properties": { @@ -12333,9 +12390,6 @@ "format": "int64", "type": "integer" }, - "cronExpression": { - "type": "string" - }, "delivery": { "$ref": "#/definitions/v2/AlarmDelivery" }, @@ -12359,17 +12413,16 @@ "prompt": { "type": "string" }, - "runOnce": { - "type": "boolean" + "trigger": { + "$ref": "#/definitions/v2/AlarmTrigger" } }, "required": [ "createdAt", - "cronExpression", "delivery", "id", "prompt", - "runOnce" + "trigger" ], "type": "object" }, diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 26b3f2440e..24623dc7e7 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -149,6 +149,63 @@ ], "type": "string" }, + "AlarmTrigger": { + "oneOf": [ + { + "properties": { + "kind": { + "enum": [ + "delay" + ], + "type": "string" + }, + "repeat": { + "type": [ + "boolean", + "null" + ] + }, + "seconds": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "kind", + "seconds" + ], + "type": "object" + }, + { + "properties": { + "dtstart": { + "type": [ + "string", + "null" + ] + }, + "kind": { + "enum": [ + "schedule" + ], + "type": "string" + }, + "rrule": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind" + ], + "title": "KindAlarmTrigger", + "type": "object" + } + ] + }, "AnalyticsConfig": { "additionalProperties": true, "properties": { @@ -10188,9 +10245,6 @@ "format": "int64", "type": "integer" }, - "cronExpression": { - "type": "string" - }, "delivery": { "$ref": "#/definitions/AlarmDelivery" }, @@ -10214,17 +10268,16 @@ "prompt": { "type": "string" }, - "runOnce": { - "type": "boolean" + "trigger": { + "$ref": "#/definitions/AlarmTrigger" } }, "required": [ "createdAt", - "cronExpression", "delivery", "id", "prompt", - "runOnce" + "trigger" ], "type": "object" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadAlarmFiredNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadAlarmFiredNotification.json index 09c0191dfc..9f85df17e7 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadAlarmFiredNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadAlarmFiredNotification.json @@ -8,15 +8,69 @@ ], "type": "string" }, + "AlarmTrigger": { + "oneOf": [ + { + "properties": { + "kind": { + "enum": [ + "delay" + ], + "type": "string" + }, + "repeat": { + "type": [ + "boolean", + "null" + ] + }, + "seconds": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "kind", + "seconds" + ], + "type": "object" + }, + { + "properties": { + "dtstart": { + "type": [ + "string", + "null" + ] + }, + "kind": { + "enum": [ + "schedule" + ], + "type": "string" + }, + "rrule": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind" + ], + "title": "KindAlarmTrigger", + "type": "object" + } + ] + }, "ThreadAlarm": { "properties": { "createdAt": { "format": "int64", "type": "integer" }, - "cronExpression": { - "type": "string" - }, "delivery": { "$ref": "#/definitions/AlarmDelivery" }, @@ -40,17 +94,16 @@ "prompt": { "type": "string" }, - "runOnce": { - "type": "boolean" + "trigger": { + "$ref": "#/definitions/AlarmTrigger" } }, "required": [ "createdAt", - "cronExpression", "delivery", "id", "prompt", - "runOnce" + "trigger" ], "type": "object" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadAlarmUpdatedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadAlarmUpdatedNotification.json index b861a7b990..0f695d70d3 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadAlarmUpdatedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadAlarmUpdatedNotification.json @@ -8,15 +8,69 @@ ], "type": "string" }, + "AlarmTrigger": { + "oneOf": [ + { + "properties": { + "kind": { + "enum": [ + "delay" + ], + "type": "string" + }, + "repeat": { + "type": [ + "boolean", + "null" + ] + }, + "seconds": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "kind", + "seconds" + ], + "type": "object" + }, + { + "properties": { + "dtstart": { + "type": [ + "string", + "null" + ] + }, + "kind": { + "enum": [ + "schedule" + ], + "type": "string" + }, + "rrule": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind" + ], + "title": "KindAlarmTrigger", + "type": "object" + } + ] + }, "ThreadAlarm": { "properties": { "createdAt": { "format": "int64", "type": "integer" }, - "cronExpression": { - "type": "string" - }, "delivery": { "$ref": "#/definitions/AlarmDelivery" }, @@ -40,17 +94,16 @@ "prompt": { "type": "string" }, - "runOnce": { - "type": "boolean" + "trigger": { + "$ref": "#/definitions/AlarmTrigger" } }, "required": [ "createdAt", - "cronExpression", "delivery", "id", "prompt", - "runOnce" + "trigger" ], "type": "object" } diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AlarmTrigger.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AlarmTrigger.ts new file mode 100644 index 0000000000..4882901911 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AlarmTrigger.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type AlarmTrigger = { "kind": "delay", seconds: number, repeat: boolean | null, } | { "kind": "schedule", dtstart: string | null, rrule: string | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadAlarm.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadAlarm.ts index 7de104cd58..9e8a13e058 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadAlarm.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadAlarm.ts @@ -2,5 +2,6 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { AlarmDelivery } from "./AlarmDelivery"; +import type { AlarmTrigger } from "./AlarmTrigger"; -export type ThreadAlarm = { id: string, cronExpression: string, prompt: string, runOnce: boolean, delivery: AlarmDelivery, createdAt: number, nextRunAt: number | null, lastRunAt: number | null, }; +export type ThreadAlarm = { id: string, trigger: AlarmTrigger, prompt: string, delivery: AlarmDelivery, createdAt: number, nextRunAt: number | null, lastRunAt: number | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index 8fa25eb0b4..1857e7318f 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -9,6 +9,7 @@ export type { AdditionalNetworkPermissions } from "./AdditionalNetworkPermission export type { AdditionalPermissionProfile } from "./AdditionalPermissionProfile"; export type { AgentMessageDeltaNotification } from "./AgentMessageDeltaNotification"; export type { AlarmDelivery } from "./AlarmDelivery"; +export type { AlarmTrigger } from "./AlarmTrigger"; export type { AnalyticsConfig } from "./AnalyticsConfig"; export type { AppBranding } from "./AppBranding"; export type { AppInfo } from "./AppInfo"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 6097b3d906..8c09eaf7ce 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -1838,41 +1838,6 @@ mod tests { assert_eq!(reason, Some("thread/realtime/outputAudio/delta")); } - #[test] - fn thread_alarm_create_is_marked_experimental() { - let request = ClientRequest::ThreadAlarmCreate { - request_id: RequestId::Integer(1), - params: v2::ThreadAlarmCreateParams { - thread_id: "thr_123".to_string(), - cron_expression: "@after-turn".to_string(), - prompt: "run tests".to_string(), - run_once: Some(false), - delivery: v2::AlarmDelivery::AfterTurn, - }, - }; - let reason = crate::experimental_api::ExperimentalApi::experimental_reason(&request); - assert_eq!(reason, Some("thread/alarm/create")); - } - - #[test] - fn thread_alarm_fired_notification_is_marked_experimental() { - let notification = ServerNotification::ThreadAlarmFired(v2::ThreadAlarmFiredNotification { - thread_id: "thr_123".to_string(), - alarm: v2::ThreadAlarm { - id: "alarm_123".to_string(), - cron_expression: "@after-turn".to_string(), - prompt: "run tests".to_string(), - run_once: false, - delivery: v2::AlarmDelivery::AfterTurn, - created_at: 1, - next_run_at: None, - last_run_at: None, - }, - }); - let reason = crate::experimental_api::ExperimentalApi::experimental_reason(¬ification); - assert_eq!(reason, Some("thread/alarm/fired")); - } - #[test] fn command_execution_request_approval_additional_permissions_is_marked_experimental() { let params = v2::CommandExecutionRequestApprovalParams { diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 6f8e840176..c5f97ecb66 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -3161,14 +3161,33 @@ pub enum AlarmDelivery { SteerCurrentTurn, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(tag = "kind", rename_all = "camelCase")] +#[ts(tag = "kind")] +#[ts(export_to = "v2/")] +pub enum AlarmTrigger { + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + Delay { + #[ts(type = "number")] + seconds: u64, + repeat: Option, + }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + Schedule { + dtstart: Option, + rrule: Option, + }, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] pub struct ThreadAlarm { pub id: String, - pub cron_expression: String, + pub trigger: AlarmTrigger, pub prompt: String, - pub run_once: bool, pub delivery: AlarmDelivery, #[ts(type = "number")] pub created_at: i64, @@ -3183,10 +3202,8 @@ pub struct ThreadAlarm { #[ts(export_to = "v2/")] pub struct ThreadAlarmCreateParams { pub thread_id: String, - pub cron_expression: String, + pub trigger: AlarmTrigger, pub prompt: String, - #[ts(optional = nullable)] - pub run_once: Option, pub delivery: AlarmDelivery, } diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index d61b5d5945..f4bf2d2b1a 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -145,10 +145,10 @@ Example with notification opt-out: - `thread/unsubscribe` — unsubscribe this connection from thread turn/item events. If this was the last subscriber, the server shuts down and unloads the thread, then emits `thread/closed`. - `thread/name/set` — set or update a thread’s user-facing name for either a loaded thread or a persisted rollout; returns `{}` on success and emits `thread/name/updated` to initialized, opted-in clients. Thread names are not required to be unique; name lookups resolve to the most recently updated thread. - `thread/unarchive` — move an archived rollout file back into the sessions directory; returns the restored `thread` on success and emits `thread/unarchived`. -- `thread/alarm/create` — create a runtime alarm on a loaded thread; accepts `cronExpression`, `prompt`, `delivery`, and optional `runOnce`, returns the created alarm, and requires the under-development feature flag `alarm_tool` to be enabled for that thread. +- `thread/alarm/create` — create a runtime alarm on a loaded thread; accepts a structured `trigger`, `prompt`, and `delivery`, returns the created alarm, and requires the under-development feature flag `alarm_tool` to be enabled for that thread. Delay triggers use `seconds` plus optional `repeat`; schedule triggers use optional `dtstart` and/or `rrule`. - `thread/alarm/delete` — delete one runtime alarm from a loaded thread by id; returns `{ deleted }` and requires `alarm_tool`. - `thread/alarm/list` — list runtime alarms for a loaded thread; returns `{ data }` and requires `alarm_tool`. -- `thread/alarm/updated` — notification emitted when a loaded thread’s alarm list changes, including run-once alarms deleting themselves after execution or alarms restored from disk. +- `thread/alarm/updated` — notification emitted when a loaded thread’s alarm list changes, including one-shot alarms deleting themselves after execution or alarms restored from disk. - `thread/alarm/fired` — notification emitted when a thread alarm fires, either for a synthetic follow-on turn or a same-turn steer delivery. - `thread/compact/start` — trigger conversation history compaction for a thread; returns `{}` immediately while progress streams through standard turn/item notifications. - `thread/shellCommand` — run a user-initiated `!` shell command against a thread; this runs unsandboxed with full access rather than inheriting the thread sandbox policy. Returns `{}` immediately while progress streams through standard turn/item notifications and any active turn receives the formatted output in its message stream. diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index ff341e0586..aca3527f80 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -15,6 +15,7 @@ use crate::thread_status::ThreadWatchManager; use codex_app_server_protocol::AccountRateLimitsUpdatedNotification; use codex_app_server_protocol::AdditionalPermissionProfile as V2AdditionalPermissionProfile; use codex_app_server_protocol::AgentMessageDeltaNotification; +use codex_app_server_protocol::AlarmTrigger; use codex_app_server_protocol::ApplyPatchApprovalParams; use codex_app_server_protocol::ApplyPatchApprovalResponse; use codex_app_server_protocol::CodexErrorInfo as V2CodexErrorInfo; @@ -166,9 +167,8 @@ struct CommandExecutionCompletionItem { fn thread_alarm_from_core(value: codex_core::alarms::ThreadAlarm) -> ThreadAlarm { ThreadAlarm { id: value.id, - cron_expression: value.cron_expression, + trigger: alarm_trigger_from_core(value.trigger), prompt: value.prompt, - run_once: value.run_once, delivery: match value.delivery { codex_core::alarms::AlarmDelivery::AfterTurn => { codex_app_server_protocol::AlarmDelivery::AfterTurn @@ -182,6 +182,17 @@ fn thread_alarm_from_core(value: codex_core::alarms::ThreadAlarm) -> ThreadAlarm last_run_at: value.last_run_at, } } + +fn alarm_trigger_from_core(value: codex_core::alarms::ThreadAlarmTrigger) -> AlarmTrigger { + match value { + codex_core::alarms::ThreadAlarmTrigger::Delay { seconds, repeat } => { + AlarmTrigger::Delay { seconds, repeat } + } + codex_core::alarms::ThreadAlarmTrigger::Schedule { dtstart, rrule } => { + AlarmTrigger::Schedule { dtstart, rrule } + } + } +} #[allow(clippy::too_many_arguments)] pub(crate) async fn apply_bespoke_event_handling( event: Event, diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 2b6dfcbbf9..942cdba4c3 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -25,6 +25,7 @@ use codex_app_server_protocol::Account; use codex_app_server_protocol::AccountLoginCompletedNotification; use codex_app_server_protocol::AccountUpdatedNotification; use codex_app_server_protocol::AlarmDelivery as ApiAlarmDelivery; +use codex_app_server_protocol::AlarmTrigger as ApiAlarmTrigger; use codex_app_server_protocol::AppInfo; use codex_app_server_protocol::AppsListParams; use codex_app_server_protocol::AppsListResponse; @@ -3704,9 +3705,8 @@ impl CodexMessageProcessor { ) { let ThreadAlarmCreateParams { thread_id, - cron_expression, + trigger, prompt, - run_once, delivery, } = params; let Some(thread) = self.load_alarm_thread(request_id.clone(), &thread_id).await else { @@ -3714,9 +3714,8 @@ impl CodexMessageProcessor { }; match thread .create_alarm( - cron_expression, + alarm_trigger_to_core(trigger), prompt, - run_once.unwrap_or(false), alarm_delivery_to_core(delivery), ) .await @@ -9053,12 +9052,33 @@ fn api_alarm_delivery_from_core(value: codex_core::alarms::AlarmDelivery) -> Api } } +fn alarm_trigger_to_core(value: ApiAlarmTrigger) -> codex_core::alarms::ThreadAlarmTrigger { + match value { + ApiAlarmTrigger::Delay { seconds, repeat } => { + codex_core::alarms::ThreadAlarmTrigger::Delay { seconds, repeat } + } + ApiAlarmTrigger::Schedule { dtstart, rrule } => { + codex_core::alarms::ThreadAlarmTrigger::Schedule { dtstart, rrule } + } + } +} + +fn api_alarm_trigger_from_core(value: codex_core::alarms::ThreadAlarmTrigger) -> ApiAlarmTrigger { + match value { + codex_core::alarms::ThreadAlarmTrigger::Delay { seconds, repeat } => { + ApiAlarmTrigger::Delay { seconds, repeat } + } + codex_core::alarms::ThreadAlarmTrigger::Schedule { dtstart, rrule } => { + ApiAlarmTrigger::Schedule { dtstart, rrule } + } + } +} + fn api_thread_alarm_from_core(value: codex_core::alarms::ThreadAlarm) -> ApiThreadAlarm { ApiThreadAlarm { id: value.id, - cron_expression: value.cron_expression, + trigger: api_alarm_trigger_from_core(value.trigger), prompt: value.prompt, - run_once: value.run_once, delivery: api_alarm_delivery_from_core(value.delivery), created_at: value.created_at, next_run_at: value.next_run_at, diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 0a9dfb521e..766e1af075 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -24,6 +24,7 @@ async-trait = { workspace = true } base64 = { workspace = true } bm25 = { workspace = true } chrono = { workspace = true, features = ["serde"] } +chrono-tz = { workspace = true } clap = { workspace = true, features = ["derive"] } codex-analytics = { workspace = true } codex-api = { workspace = true } @@ -87,6 +88,7 @@ once_cell = { workspace = true } rand = { workspace = true } regex-lite = { workspace = true } reqwest = { workspace = true, features = ["json", "stream"] } +rrule = { workspace = true } rmcp = { workspace = true, default-features = false, features = [ "base64", "macros", diff --git a/codex-rs/core/src/alarm_trigger.rs b/codex-rs/core/src/alarm_trigger.rs new file mode 100644 index 0000000000..34ea9f9e7b --- /dev/null +++ b/codex-rs/core/src/alarm_trigger.rs @@ -0,0 +1,606 @@ +//! Trigger validation and next-fire calculation for persistent thread alarms. +//! +//! This module keeps calendar and delay scheduling details out of `alarms.rs`. +//! It owns the persisted trigger shape, local wall-clock schedule normalization, +//! and RRULE-backed recurrence evaluation. + +use chrono::DateTime; +use chrono::Duration as ChronoDuration; +use chrono::LocalResult; +use chrono::NaiveDateTime; +use chrono::TimeZone; +use chrono::Utc; +use rrule::RRuleSet; +use rrule::Tz; +use serde::Deserialize; +use serde::Serialize; +use std::time::Duration; + +const LOCAL_DATE_TIME_FORMAT: &str = "%Y-%m-%dT%H:%M:%S"; +const RRULE_DATE_TIME_FORMAT: &str = "%Y%m%dT%H%M%S"; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "kebab-case")] +pub enum AlarmTrigger { + Delay { + seconds: u64, + repeat: Option, + }, + Schedule { + dtstart: Option, + rrule: Option, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct TriggerTiming { + pub(crate) trigger: AlarmTrigger, + pub(crate) pending_run: bool, + pub(crate) next_run_at: Option, + pub(crate) timer_delay: Option, +} + +impl AlarmTrigger { + pub(crate) fn is_recurring(&self) -> bool { + match self { + Self::Delay { repeat, .. } => repeat.unwrap_or(false), + Self::Schedule { rrule, .. } => rrule.as_ref().is_some_and(|rrule| !rrule.is_empty()), + } + } + + pub(crate) fn display(&self) -> String { + match self { + Self::Delay { seconds, repeat } => { + let suffix = if repeat.unwrap_or(false) { + ", repeat" + } else { + "" + }; + format!("delay {seconds}s{suffix}") + } + Self::Schedule { dtstart, rrule } => match (dtstart, rrule) { + (Some(dtstart), Some(rrule)) => format!("schedule {dtstart}; {rrule}"), + (Some(dtstart), None) => format!("schedule {dtstart}"), + (None, Some(rrule)) => format!("schedule {rrule}"), + (None, None) => "invalid schedule".to_string(), + }, + } + } +} + +pub(crate) fn timing_for_new_trigger( + trigger: AlarmTrigger, + created_at: DateTime, + now: DateTime, +) -> Result { + let timezone = local_timezone(); + timing_for_new_trigger_with_timezone(trigger, created_at, now, timezone) +} + +pub(crate) fn timing_for_restored_trigger( + trigger: AlarmTrigger, + created_at: i64, + persisted_pending_run: bool, + persisted_next_run_at: Option, + now: DateTime, +) -> Result { + let timezone = local_timezone(); + timing_for_restored_trigger_with_timezone( + trigger, + created_at, + persisted_pending_run, + persisted_next_run_at, + now, + timezone, + ) +} + +pub(crate) fn next_run_after_due( + trigger: &AlarmTrigger, + created_at: i64, + now: DateTime, +) -> Result, String> { + let timezone = local_timezone(); + next_run_after_due_with_timezone(trigger, created_at, now, timezone) +} + +fn timing_for_new_trigger_with_timezone( + trigger: AlarmTrigger, + created_at: DateTime, + now: DateTime, + timezone: Tz, +) -> Result { + let normalized = normalize_trigger(trigger, now, timezone)?; + match &normalized { + AlarmTrigger::Delay { seconds, repeat } => { + let repeat = repeat.unwrap_or(false); + if repeat && *seconds == 0 { + return Err("delay.repeat requires seconds to be greater than 0".to_string()); + } + let next_run_at = checked_add_seconds(created_at, *seconds)?; + let pending_run = next_run_at <= now; + let next_run_at = if repeat { + if pending_run { + next_delay_recurring_run_at(created_at, *seconds, now)? + } else { + Some(next_run_at.timestamp()) + } + } else { + Some(next_run_at.timestamp()) + }; + Ok(timing(normalized, pending_run, next_run_at, now)) + } + AlarmTrigger::Schedule { rrule: None, .. } => { + let due_at = schedule_dtstart_utc(&normalized, timezone)?; + Ok(timing( + normalized, + due_at <= now, + Some(due_at.timestamp()), + now, + )) + } + AlarmTrigger::Schedule { rrule: Some(_), .. } => { + let first = first_schedule_occurrence(&normalized, timezone)?; + let Some(first) = first else { + return Ok(timing( + normalized, /*pending_run*/ false, /*next_run_at*/ None, now, + )); + }; + let pending_run = first <= now.timestamp(); + let next_run_at = if pending_run { + next_schedule_occurrence_after(&normalized, now, timezone)? + } else { + Some(first) + }; + Ok(timing(normalized, pending_run, next_run_at, now)) + } + } +} + +fn timing_for_restored_trigger_with_timezone( + trigger: AlarmTrigger, + created_at: i64, + persisted_pending_run: bool, + persisted_next_run_at: Option, + now: DateTime, + timezone: Tz, +) -> Result { + let normalized = normalize_trigger(trigger, now, timezone)?; + match &normalized { + AlarmTrigger::Delay { seconds, repeat } => { + let repeat = repeat.unwrap_or(false); + if repeat && *seconds == 0 { + return Err("delay.repeat requires seconds to be greater than 0".to_string()); + } + let next_run_at = persisted_next_run_at + .or_else(|| next_delay_run_at(created_at, *seconds)) + .ok_or_else(|| "delay next run time is out of range".to_string())?; + let due = next_run_at <= now.timestamp(); + let pending_run = persisted_pending_run || due; + let next_run_at = if repeat && due { + next_delay_recurring_run_at_from_timestamp(created_at, *seconds, now)? + } else { + Some(next_run_at) + }; + Ok(timing(normalized, pending_run, next_run_at, now)) + } + AlarmTrigger::Schedule { rrule: None, .. } => { + let next_run_at = persisted_next_run_at + .or_else(|| { + schedule_dtstart_utc(&normalized, timezone) + .ok() + .map(|dt| dt.timestamp()) + }) + .ok_or_else(|| "schedule next run time is out of range".to_string())?; + Ok(timing( + normalized, + persisted_pending_run || next_run_at <= now.timestamp(), + Some(next_run_at), + now, + )) + } + AlarmTrigger::Schedule { rrule: Some(_), .. } => { + let due = persisted_next_run_at.is_none_or(|next| next <= now.timestamp()); + let pending_run = persisted_pending_run || due; + let next_run_at = if due { + next_schedule_occurrence_after(&normalized, now, timezone)? + } else { + persisted_next_run_at + }; + Ok(timing(normalized, pending_run, next_run_at, now)) + } + } +} + +fn next_run_after_due_with_timezone( + trigger: &AlarmTrigger, + created_at: i64, + now: DateTime, + timezone: Tz, +) -> Result, String> { + match trigger { + AlarmTrigger::Delay { seconds, repeat } => { + if repeat.unwrap_or(false) { + next_delay_recurring_run_at_from_timestamp(created_at, *seconds, now) + } else { + Ok(None) + } + } + AlarmTrigger::Schedule { rrule: None, .. } => Ok(None), + AlarmTrigger::Schedule { rrule: Some(_), .. } => { + next_schedule_occurrence_after(trigger, now, timezone) + } + } +} + +fn normalize_trigger( + trigger: AlarmTrigger, + now: DateTime, + timezone: Tz, +) -> Result { + match trigger { + AlarmTrigger::Delay { seconds, repeat } => { + if repeat.unwrap_or(false) && seconds == 0 { + return Err("delay.repeat requires seconds to be greater than 0".to_string()); + } + Ok(AlarmTrigger::Delay { seconds, repeat }) + } + AlarmTrigger::Schedule { dtstart, rrule } => { + let dtstart = normalize_optional_string(dtstart); + let rrule = normalize_optional_string(rrule); + if dtstart.is_none() && rrule.is_none() { + return Err("schedule trigger requires dtstart, rrule, or both".to_string()); + } + let dtstart = match (dtstart, rrule.as_ref()) { + (Some(dtstart), _) => { + validate_dtstart(&dtstart, timezone)?; + Some(dtstart) + } + (None, Some(_)) => Some(format_local_dtstart(now, timezone)), + (None, None) => None, + }; + let normalized = AlarmTrigger::Schedule { dtstart, rrule }; + if matches!(normalized, AlarmTrigger::Schedule { rrule: Some(_), .. }) { + parse_rrule_set(&normalized, timezone)?; + } + Ok(normalized) + } + } +} + +fn timing( + trigger: AlarmTrigger, + pending_run: bool, + next_run_at: Option, + now: DateTime, +) -> TriggerTiming { + let keep_timer_for_pending = trigger.is_recurring(); + let timer_delay = next_run_at + .filter(|_| !pending_run || keep_timer_for_pending) + .and_then(|next| timer_delay(next, now)); + TriggerTiming { + trigger, + pending_run, + next_run_at, + timer_delay, + } +} + +fn normalize_optional_string(value: Option) -> Option { + value + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +fn checked_add_seconds(start: DateTime, seconds: u64) -> Result, String> { + let seconds = i64::try_from(seconds) + .map_err(|_| "delay seconds value is too large to schedule".to_string())?; + start + .checked_add_signed(ChronoDuration::seconds(seconds)) + .ok_or_else(|| "delay next run time is out of range".to_string()) +} + +fn next_delay_run_at(created_at: i64, seconds: u64) -> Option { + let seconds = i64::try_from(seconds).ok()?; + created_at.checked_add(seconds) +} + +fn next_delay_recurring_run_at( + created_at: DateTime, + seconds: u64, + now: DateTime, +) -> Result, String> { + next_delay_recurring_run_at_from_timestamp(created_at.timestamp(), seconds, now) +} + +fn next_delay_recurring_run_at_from_timestamp( + created_at: i64, + seconds: u64, + now: DateTime, +) -> Result, String> { + let seconds = i64::try_from(seconds) + .map_err(|_| "delay seconds value is too large to schedule".to_string())?; + if seconds <= 0 { + return Err("delay.repeat requires seconds to be greater than 0".to_string()); + } + let elapsed = now.timestamp().saturating_sub(created_at); + let completed_intervals = elapsed.div_euclid(seconds) + 1; + created_at + .checked_add( + completed_intervals + .checked_mul(seconds) + .ok_or_else(|| "delay next run time is out of range".to_string())?, + ) + .map(Some) + .ok_or_else(|| "delay next run time is out of range".to_string()) +} + +fn timer_delay(next_run_at: i64, now: DateTime) -> Option { + if next_run_at <= now.timestamp() { + return Some(Duration::ZERO); + } + u64::try_from(next_run_at - now.timestamp()) + .ok() + .map(Duration::from_secs) +} + +fn schedule_dtstart_utc(trigger: &AlarmTrigger, timezone: Tz) -> Result, String> { + let AlarmTrigger::Schedule { + dtstart: Some(dtstart), + .. + } = trigger + else { + return Err("schedule trigger requires dtstart".to_string()); + }; + local_dtstart_to_utc(dtstart, timezone) +} + +fn first_schedule_occurrence(trigger: &AlarmTrigger, timezone: Tz) -> Result, String> { + let dtstart = schedule_dtstart_utc(trigger, timezone)?; + let after = dtstart + .checked_sub_signed(ChronoDuration::seconds(1)) + .unwrap_or(dtstart); + next_schedule_occurrence_after(trigger, after, timezone) +} + +fn next_schedule_occurrence_after( + trigger: &AlarmTrigger, + after: DateTime, + timezone: Tz, +) -> Result, String> { + let set = parse_rrule_set(trigger, timezone)?; + let result = set.after(after.with_timezone(&timezone)).all(1); + Ok(result + .dates + .into_iter() + .next() + .map(|next| next.with_timezone(&Utc).timestamp())) +} + +fn parse_rrule_set(trigger: &AlarmTrigger, timezone: Tz) -> Result { + let AlarmTrigger::Schedule { + dtstart: Some(dtstart), + rrule: Some(rrule), + } = trigger + else { + return Err("schedule trigger requires dtstart and rrule".to_string()); + }; + let naive = parse_dtstart(dtstart)?; + let raw_rrule = rrule + .strip_prefix("RRULE:") + .or_else(|| rrule.strip_prefix("rrule:")) + .unwrap_or(rrule); + let rrule_set = format!( + "DTSTART;TZID={}:{}\nRRULE:{}", + timezone.name(), + naive.format(RRULE_DATE_TIME_FORMAT), + raw_rrule + ); + rrule_set + .parse::() + .map_err(|err| format!("invalid schedule rrule `{rrule}`: {err}")) +} + +fn validate_dtstart(dtstart: &str, timezone: Tz) -> Result<(), String> { + local_dtstart_to_utc(dtstart, timezone).map(|_| ()) +} + +fn local_dtstart_to_utc(dtstart: &str, timezone: Tz) -> Result, String> { + let naive = parse_dtstart(dtstart)?; + match timezone.from_local_datetime(&naive) { + LocalResult::Single(dt) => Ok(dt.with_timezone(&Utc)), + LocalResult::Ambiguous(earliest, _) => Ok(earliest.with_timezone(&Utc)), + LocalResult::None => Err(format!( + "schedule dtstart `{dtstart}` does not exist in local timezone {timezone}" + )), + } +} + +fn parse_dtstart(dtstart: &str) -> Result { + NaiveDateTime::parse_from_str(dtstart, LOCAL_DATE_TIME_FORMAT) + .map_err(|_| format!("schedule dtstart `{dtstart}` must use format YYYY-MM-DDTHH:MM:SS")) +} + +fn format_local_dtstart(now: DateTime, timezone: Tz) -> String { + now.with_timezone(&timezone) + .naive_local() + .format(LOCAL_DATE_TIME_FORMAT) + .to_string() +} + +fn local_timezone() -> Tz { + iana_time_zone::get_timezone() + .ok() + .and_then(|timezone| timezone.parse::().ok()) + .map(Tz::Tz) + .unwrap_or(Tz::UTC) +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::TimeZone; + use pretty_assertions::assert_eq; + + const TS_1: i64 = 1; + const TS_100: i64 = 100; + const TS_135: i64 = 135; + const TS_1_700_000_000: i64 = 1_700_000_000; + + fn utc(timestamp: i64) -> DateTime { + Utc.timestamp_opt(timestamp, 0) + .single() + .expect("valid timestamp") + } + + #[test] + fn delay_one_shot_becomes_pending_when_due() { + let timing = timing_for_new_trigger_with_timezone( + AlarmTrigger::Delay { + seconds: 0, + repeat: None, + }, + utc(TS_100), + utc(TS_100), + Tz::UTC, + ) + .expect("trigger should be valid"); + assert_eq!( + timing, + TriggerTiming { + trigger: AlarmTrigger::Delay { + seconds: 0, + repeat: None, + }, + pending_run: true, + next_run_at: Some(100), + timer_delay: None, + } + ); + } + + #[test] + fn delay_repeat_rejects_zero_seconds() { + assert_eq!( + timing_for_new_trigger_with_timezone( + AlarmTrigger::Delay { + seconds: 0, + repeat: Some(true), + }, + utc(TS_100), + utc(TS_100), + Tz::UTC, + ) + .expect_err("zero repeat should be invalid"), + "delay.repeat requires seconds to be greater than 0" + ); + } + + #[test] + fn delay_repeat_coalesces_overdue_runs() { + let timing = timing_for_restored_trigger_with_timezone( + AlarmTrigger::Delay { + seconds: 10, + repeat: Some(true), + }, + /*created_at*/ 100, + /*persisted_pending_run*/ false, + Some(110), + utc(TS_135), + Tz::UTC, + ) + .expect("trigger should be valid"); + assert_eq!(timing.pending_run, true); + assert_eq!(timing.next_run_at, Some(140)); + } + + #[test] + fn schedule_rrule_only_resolves_dtstart_to_now() { + let timing = timing_for_new_trigger_with_timezone( + AlarmTrigger::Schedule { + dtstart: None, + rrule: Some("FREQ=HOURLY;BYMINUTE=0;BYSECOND=0".to_string()), + }, + utc(TS_1_700_000_000), + utc(TS_1_700_000_000), + Tz::UTC, + ) + .expect("trigger should be valid"); + assert_eq!( + timing.trigger, + AlarmTrigger::Schedule { + dtstart: Some("2023-11-14T22:13:20".to_string()), + rrule: Some("FREQ=HOURLY;BYMINUTE=0;BYSECOND=0".to_string()), + } + ); + assert_eq!(timing.pending_run, false); + assert_eq!(timing.next_run_at, Some(1_700_002_800)); + } + + #[test] + fn schedule_dtstart_only_is_one_shot() { + let timing = timing_for_new_trigger_with_timezone( + AlarmTrigger::Schedule { + dtstart: Some("2024-01-01T09:00:00".to_string()), + rrule: None, + }, + utc(TS_1), + utc(TS_1), + Tz::UTC, + ) + .expect("trigger should be valid"); + assert_eq!(timing.pending_run, false); + assert_eq!(timing.next_run_at, Some(1_704_099_600)); + } + + #[test] + fn schedule_rejects_neither_dtstart_nor_rrule() { + assert_eq!( + timing_for_new_trigger_with_timezone( + AlarmTrigger::Schedule { + dtstart: None, + rrule: None, + }, + utc(TS_1), + utc(TS_1), + Tz::UTC, + ) + .expect_err("empty schedule should be invalid"), + "schedule trigger requires dtstart, rrule, or both" + ); + } + + #[test] + fn schedule_rejects_invalid_dtstart() { + assert_eq!( + timing_for_new_trigger_with_timezone( + AlarmTrigger::Schedule { + dtstart: Some("2024-01-01 09:00:00".to_string()), + rrule: None, + }, + utc(TS_1), + utc(TS_1), + Tz::UTC, + ) + .expect_err("bad dtstart should be invalid"), + "schedule dtstart `2024-01-01 09:00:00` must use format YYYY-MM-DDTHH:MM:SS" + ); + } + + #[test] + fn schedule_rejects_invalid_rrule() { + assert!( + timing_for_new_trigger_with_timezone( + AlarmTrigger::Schedule { + dtstart: Some("2024-01-01T09:00:00".to_string()), + rrule: Some("FREQ=NEVER".to_string()), + }, + utc(TS_1), + utc(TS_1), + Tz::UTC, + ) + .expect_err("bad rrule should be invalid") + .contains("invalid schedule rrule") + ); + } +} diff --git a/codex-rs/core/src/alarms.rs b/codex-rs/core/src/alarms.rs index 3ebe3834cb..34d38ba760 100644 --- a/codex-rs/core/src/alarms.rs +++ b/codex-rs/core/src/alarms.rs @@ -1,12 +1,14 @@ //! Persistent thread-local alarm scheduling for follow-on turns and same-turn steer delivery. //! -//! This module owns the in-memory alarm registry, schedule parsing, the hidden +//! This module owns the in-memory alarm registry, trigger evaluation, the hidden //! alarm prompt injected when an alarm fires, and the JSON sidecar format used //! to restore alarms after a harness restart. -use chrono::DateTime; -use chrono::Duration as ChronoDuration; -use chrono::TimeZone; +use crate::alarm_trigger::AlarmTrigger; +use crate::alarm_trigger::TriggerTiming; +use crate::alarm_trigger::next_run_after_due; +use crate::alarm_trigger::timing_for_new_trigger; +use crate::alarm_trigger::timing_for_restored_trigger; use chrono::Utc; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseInputItem; @@ -19,16 +21,14 @@ use std::path::PathBuf; use tokio_util::sync::CancellationToken; use uuid::Uuid; -pub const AFTER_TURN_CRON_EXPRESSION: &str = "@after-turn"; -const EVERY_PREFIX: &str = "@every "; -const EVERY_SECONDS_PREFIX: &str = "@every:"; pub const ALARM_UPDATED_BACKGROUND_EVENT_PREFIX: &str = "alarm_updated:"; pub const ALARM_FIRED_BACKGROUND_EVENT_PREFIX: &str = "alarm_fired:"; pub const MAX_ACTIVE_ALARMS_PER_THREAD: usize = 256; -const MAX_EVERY_SECONDS: u64 = i64::MAX as u64; const ONE_SHOT_ALARM_PROMPT: &str = include_str!("../templates/alarms/one_shot_prompt.md"); const RECURRING_ALARM_PROMPT: &str = include_str!("../templates/alarms/recurring_prompt.md"); +pub use crate::alarm_trigger::AlarmTrigger as ThreadAlarmTrigger; + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum AlarmDelivery { @@ -48,9 +48,8 @@ impl AlarmDelivery { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ThreadAlarm { pub id: String, - pub cron_expression: String, + pub trigger: AlarmTrigger, pub prompt: String, - pub run_once: bool, pub delivery: AlarmDelivery, pub created_at: i64, pub next_run_at: Option, @@ -60,9 +59,9 @@ pub struct ThreadAlarm { #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct AlarmInvocationContext { pub(crate) current_alarm_id: String, - pub(crate) cron_expression: String, + pub(crate) trigger: AlarmTrigger, pub(crate) prompt: String, - pub(crate) run_once: bool, + pub(crate) recurring: bool, pub(crate) delivery: AlarmDelivery, } @@ -70,17 +69,16 @@ pub(crate) struct AlarmInvocationContext { pub(crate) struct ClaimedAlarm { pub(crate) alarm: ThreadAlarm, pub(crate) context: AlarmInvocationContext, - pub(crate) deleted_run_once_alarm: bool, + pub(crate) deleted_one_shot_alarm: bool, } #[derive(Debug)] pub(crate) struct CreateAlarm { pub(crate) id: String, - pub(crate) cron_expression: String, + pub(crate) trigger: AlarmTrigger, pub(crate) prompt: String, - pub(crate) run_once: bool, pub(crate) delivery: AlarmDelivery, - pub(crate) now: DateTime, + pub(crate) now: chrono::DateTime, } #[derive(Debug, Default)] @@ -91,17 +89,10 @@ pub(crate) struct AlarmsState { #[derive(Debug)] pub(crate) struct AlarmRuntime { pub(crate) alarm: ThreadAlarm, - schedule: AlarmSchedule, pending_run: bool, pub(crate) timer_cancel: Option, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum AlarmSchedule { - AfterTurn, - EverySeconds(u64), -} - #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub(crate) struct PersistedAlarm { pub(crate) alarm: ThreadAlarm, @@ -110,54 +101,7 @@ pub(crate) struct PersistedAlarm { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) struct AlarmTimerSpec { - pub(crate) seconds: u64, - pub(crate) initial_delay: std::time::Duration, -} - -impl AlarmSchedule { - pub(crate) fn parse(cron_expression: &str) -> Result { - if cron_expression == AFTER_TURN_CRON_EXPRESSION { - return Ok(Self::AfterTurn); - } - - if let Some(seconds) = cron_expression - .strip_prefix(EVERY_PREFIX) - .map(str::trim) - .and_then(parse_duration_literal) - { - return Self::parse_every_seconds(seconds, cron_expression); - } - - if let Some(seconds) = cron_expression - .strip_prefix(EVERY_SECONDS_PREFIX) - .and_then(|raw| raw.parse::().ok()) - .filter(|seconds| *seconds > 0) - { - return Self::parse_every_seconds(seconds, cron_expression); - } - - Err(format!( - "unsupported cron_expression `{cron_expression}`; supported values are `{AFTER_TURN_CRON_EXPRESSION}`, `@every 5m`, or `@every:300`" - )) - } - - fn parse_every_seconds(seconds: u64, cron_expression: &str) -> Result { - if seconds > MAX_EVERY_SECONDS { - return Err(format!( - "unsupported cron_expression `{cron_expression}`; @every values must be between 1 and {MAX_EVERY_SECONDS} seconds" - )); - } - Ok(Self::EverySeconds(seconds)) - } - - fn next_run_at(self, now: DateTime) -> Option> { - match self { - Self::AfterTurn => None, - Self::EverySeconds(seconds) => { - now.checked_add_signed(ChronoDuration::seconds(i64::try_from(seconds).ok()?)) - } - } - } + pub(crate) delay: std::time::Duration, } impl AlarmsState { @@ -197,7 +141,7 @@ impl AlarmsState { &mut self, create_alarm: CreateAlarm, timer_cancel: Option, - ) -> Result { + ) -> Result<(ThreadAlarm, Option), String> { if self.alarms.len() >= MAX_ACTIVE_ALARMS_PER_THREAD { return Err(format!( "too many active alarms; each thread supports at most {MAX_ACTIVE_ALARMS_PER_THREAD} alarms" @@ -205,47 +149,41 @@ impl AlarmsState { } let CreateAlarm { id, - cron_expression, + trigger, prompt, - run_once, delivery, now, } = create_alarm; - let schedule = AlarmSchedule::parse(&cron_expression)?; - let next_run_at = match schedule { - AlarmSchedule::AfterTurn => None, - AlarmSchedule::EverySeconds(_) => Some(schedule.next_run_at(now).ok_or_else(|| { - format!( - "unsupported cron_expression `{cron_expression}`; next run time is out of range" - ) - })?), - }; + let TriggerTiming { + trigger, + pending_run, + next_run_at, + timer_delay, + } = timing_for_new_trigger(trigger, now, now)?; let alarm = ThreadAlarm { id: id.clone(), - cron_expression, + trigger, prompt, - run_once, delivery, created_at: now.timestamp(), - next_run_at: next_run_at.map(|value| value.timestamp()), + next_run_at, last_run_at: None, }; self.alarms.insert( id, AlarmRuntime { alarm: alarm.clone(), - schedule, - pending_run: matches!(schedule, AlarmSchedule::AfterTurn), + pending_run, timer_cancel, }, ); - Ok(alarm) + Ok((alarm, timer_delay.map(|delay| AlarmTimerSpec { delay }))) } pub(crate) fn restore_alarm( &mut self, persisted: PersistedAlarm, - now: DateTime, + now: chrono::DateTime, timer_cancel: Option, ) -> Result, String> { if self.alarms.len() >= MAX_ACTIVE_ALARMS_PER_THREAD { @@ -253,46 +191,37 @@ impl AlarmsState { "too many persisted alarms; each thread supports at most {MAX_ACTIVE_ALARMS_PER_THREAD} alarms" )); } - let schedule = AlarmSchedule::parse(&persisted.alarm.cron_expression)?; - let mut alarm = persisted.alarm; - let mut pending_run = persisted.pending_run; - let timer_spec = match schedule { - AlarmSchedule::AfterTurn => { - alarm.next_run_at = None; - None - } - AlarmSchedule::EverySeconds(seconds) => { - let initial_delay = match alarm - .next_run_at - .and_then(|timestamp| Utc.timestamp_opt(timestamp, 0).single()) - { - Some(next_run_at) if next_run_at > now => (next_run_at - now) - .to_std() - .map_err(|err| err.to_string())?, - _ => { - pending_run = true; - alarm.next_run_at = - schedule.next_run_at(now).map(|value| value.timestamp()); - std::time::Duration::from_secs(seconds) - } - }; - Some(AlarmTimerSpec { - seconds, - initial_delay, - }) - } + let PersistedAlarm { + alarm, + pending_run: persisted_pending_run, + } = persisted; + let TriggerTiming { + trigger, + pending_run, + next_run_at, + timer_delay, + } = timing_for_restored_trigger( + alarm.trigger, + alarm.created_at, + persisted_pending_run, + alarm.next_run_at, + now, + )?; + let alarm = ThreadAlarm { + trigger, + next_run_at, + ..alarm }; let id = alarm.id.clone(); self.alarms.insert( id, AlarmRuntime { alarm, - schedule, pending_run, timer_cancel, }, ); - Ok(timer_spec) + Ok(timer_delay.map(|delay| AlarmTimerSpec { delay })) } pub(crate) fn remove_alarm(&mut self, id: &str) -> Option { @@ -309,37 +238,51 @@ impl AlarmsState { } } - pub(crate) fn mark_after_turn_alarms_due(&mut self) -> bool { - let mut changed = false; - for runtime in self.alarms.values_mut() { - if matches!(runtime.schedule, AlarmSchedule::AfterTurn) && !runtime.pending_run { - runtime.pending_run = true; - changed = true; - } - } - changed - } - - pub(crate) fn mark_alarm_due(&mut self, id: &str, now: DateTime) -> bool { + pub(crate) fn mark_alarm_due(&mut self, id: &str, now: chrono::DateTime) -> bool { let Some(runtime) = self.alarms.get_mut(id) else { return false; }; let mut changed = !runtime.pending_run; runtime.pending_run = true; - let next_run_at = runtime - .schedule - .next_run_at(now) - .map(|value| value.timestamp()); - if runtime.alarm.next_run_at != next_run_at { - runtime.alarm.next_run_at = next_run_at; - changed = true; + match next_run_after_due(&runtime.alarm.trigger, runtime.alarm.created_at, now) { + Ok(next_run_at) if runtime.alarm.next_run_at != next_run_at => { + runtime.alarm.next_run_at = next_run_at; + changed = true; + } + Ok(_) => {} + Err(err) => { + tracing::warn!( + "failed to advance alarm {} trigger: {err}", + runtime.alarm.id + ); + } } changed } + pub(crate) fn timer_spec_for_alarm( + &self, + id: &str, + now: chrono::DateTime, + ) -> Option { + let runtime = self.alarms.get(id)?; + let next_run_at = runtime.alarm.next_run_at?; + if runtime.pending_run && !runtime.alarm.trigger.is_recurring() { + return None; + } + Some(AlarmTimerSpec { + delay: if next_run_at <= now.timestamp() { + std::time::Duration::ZERO + } else { + let delay = u64::try_from(next_run_at - now.timestamp()).ok()?; + std::time::Duration::from_secs(delay) + }, + }) + } + pub(crate) fn claim_next_alarm( &mut self, - now: DateTime, + now: chrono::DateTime, can_after_turn: bool, can_steer_current_turn: bool, ) -> Option { @@ -372,12 +315,12 @@ impl AlarmsState { let runtime = self.alarms.remove(&next_alarm_id)?; let AlarmRuntime { mut alarm, - schedule, pending_run: _, timer_cancel, } = runtime; - let deleted_run_once_alarm = alarm.run_once; - if deleted_run_once_alarm { + let is_recurring = alarm.trigger.is_recurring(); + let deleted_one_shot_alarm = !is_recurring; + if deleted_one_shot_alarm { if let Some(cancel) = timer_cancel.as_ref() { cancel.cancel(); } @@ -387,7 +330,6 @@ impl AlarmsState { alarm.id.clone(), AlarmRuntime { alarm: alarm.clone(), - schedule, pending_run: false, timer_cancel, }, @@ -397,21 +339,21 @@ impl AlarmsState { alarm: alarm.clone(), context: AlarmInvocationContext { current_alarm_id: alarm.id, - cron_expression: alarm.cron_expression, + trigger: alarm.trigger, prompt: alarm.prompt, - run_once: alarm.run_once, + recurring: is_recurring, delivery: actual_delivery, }, - deleted_run_once_alarm, + deleted_one_shot_alarm, }) } } pub(crate) fn alarm_prompt_input_item(alarm: &AlarmInvocationContext) -> ResponseInputItem { - let text = if alarm.run_once { - render_alarm_prompt_template(ONE_SHOT_ALARM_PROMPT, alarm) - } else { + let text = if alarm.recurring { render_alarm_prompt_template(RECURRING_ALARM_PROMPT, alarm) + } else { + render_alarm_prompt_template(ONE_SHOT_ALARM_PROMPT, alarm) }; ResponseInputItem::Message { role: "developer".to_string(), @@ -422,7 +364,7 @@ pub(crate) fn alarm_prompt_input_item(alarm: &AlarmInvocationContext) -> Respons fn render_alarm_prompt_template(template: &str, alarm: &AlarmInvocationContext) -> String { template .replace("{{CURRENT_ALARM_ID}}", &alarm.current_alarm_id) - .replace("{{SCHEDULE}}", &alarm.cron_expression) + .replace("{{TRIGGER}}", &alarm.trigger.display()) .replace("{{PROMPT}}", &alarm.prompt) .replace("{{DELIVERY}}", alarm.delivery.as_str()) .trim_end() @@ -445,12 +387,25 @@ pub(crate) async fn load_alarm_sidecar(rollout_path: &Path) -> Result = serde_json::from_slice(&bytes).map_err(|err| { format!( "failed to parse alarm sidecar `{}`: {err}", sidecar_path.display() ) - }) + })?; + let mut alarms = Vec::new(); + for raw_alarm in raw_alarms { + match serde_json::from_value::(raw_alarm) { + Ok(alarm) => alarms.push(alarm), + Err(err) => { + tracing::warn!( + "skipping invalid persisted alarm from `{}`: {err}", + sidecar_path.display() + ); + } + } + } + Ok(alarms) } pub(crate) async fn write_alarm_sidecar( @@ -523,43 +478,19 @@ pub(crate) async fn write_alarm_sidecar( } } -fn parse_duration_literal(raw: &str) -> Option { - let mut digits = String::new(); - let mut unit = String::new(); - for ch in raw.chars() { - if ch.is_ascii_digit() && unit.is_empty() { - digits.push(ch); - } else if !ch.is_whitespace() { - unit.push(ch); - } - } - let value = digits.parse::().ok().filter(|value| *value > 0)?; - match unit.as_str() { - "s" | "sec" | "secs" | "second" | "seconds" => Some(value), - "m" | "min" | "mins" | "minute" | "minutes" => value.checked_mul(60), - "h" | "hr" | "hrs" | "hour" | "hours" => value.checked_mul(60 * 60), - "d" | "day" | "days" => value.checked_mul(60 * 60 * 24), - _ => None, - } -} - #[cfg(test)] mod tests { - use super::AFTER_TURN_CRON_EXPRESSION; use super::AlarmDelivery; use super::AlarmInvocationContext; - use super::AlarmSchedule; use super::AlarmsState; use super::CreateAlarm; use super::MAX_ACTIVE_ALARMS_PER_THREAD; - use super::MAX_EVERY_SECONDS; use super::PersistedAlarm; use super::alarm_prompt_input_item; use super::alarm_sidecar_path_for_rollout; use super::load_alarm_sidecar; use super::write_alarm_sidecar; - use chrono::DateTime; - use chrono::Duration as ChronoDuration; + use crate::alarm_trigger::AlarmTrigger; use chrono::TimeZone; use chrono::Utc; use codex_protocol::models::ContentItem; @@ -567,81 +498,31 @@ mod tests { use pretty_assertions::assert_eq; use tempfile::TempDir; - #[test] - fn parses_supported_alarm_schedules() { - assert_eq!( - AlarmSchedule::parse(AFTER_TURN_CRON_EXPRESSION), - Ok(AlarmSchedule::AfterTurn) - ); - assert_eq!( - AlarmSchedule::parse("@every 5m"), - Ok(AlarmSchedule::EverySeconds(300)) - ); - assert_eq!( - AlarmSchedule::parse("@every:3600"), - Ok(AlarmSchedule::EverySeconds(3600)) - ); + const ZERO_SECONDS: u64 = 0; + const TEN_SECONDS: u64 = 10; + const SIXTY_SECONDS: u64 = 60; + + fn delay(seconds: u64, repeat: Option) -> AlarmTrigger { + AlarmTrigger::Delay { seconds, repeat } } #[test] - fn rejects_overflowing_every_alarm_schedules() { - let too_large = MAX_EVERY_SECONDS + 1; - assert_eq!( - AlarmSchedule::parse(&format!("@every:{too_large}")), - Err(format!( - "unsupported cron_expression `@every:{too_large}`; @every values must be between 1 and {MAX_EVERY_SECONDS} seconds" - )) - ); - assert_eq!( - AlarmSchedule::parse(&format!("@every {too_large}s")), - Err(format!( - "unsupported cron_expression `@every {too_large}s`; @every values must be between 1 and {MAX_EVERY_SECONDS} seconds" - )) - ); - } - - #[test] - fn create_alarm_rejects_every_schedule_when_next_run_at_is_out_of_range() { - let now = DateTime::::MAX_UTC - ChronoDuration::seconds(1); - let mut alarms = AlarmsState::default(); - - let result = alarms.create_alarm( - CreateAlarm { - id: "alarm-1".to_string(), - cron_expression: "@every:2".to_string(), - prompt: "overflow".to_string(), - run_once: false, - delivery: AlarmDelivery::AfterTurn, - now, - }, - /*timer_cancel*/ None, - ); - - assert_eq!( - result, - Err( - "unsupported cron_expression `@every:2`; next run time is out of range".to_string() - ) - ); - } - - #[test] - fn claim_run_once_alarm_removes_it() { + fn claim_one_shot_alarm_removes_it() { let now = Utc.timestamp_opt(100, 0).single().expect("valid timestamp"); let mut alarms = AlarmsState::default(); - let alarm = alarms + let (alarm, timer_spec) = alarms .create_alarm( CreateAlarm { id: "alarm-1".to_string(), - cron_expression: AFTER_TURN_CRON_EXPRESSION.to_string(), + trigger: delay(ZERO_SECONDS, /*repeat*/ None), prompt: "run tests".to_string(), - run_once: true, delivery: AlarmDelivery::AfterTurn, now, }, /*timer_cancel*/ None, ) .expect("alarm should be created"); + assert_eq!(timer_spec, None); assert_eq!(alarms.list_alarms(), vec![alarm]); let claimed = alarms @@ -650,7 +531,7 @@ mod tests { ) .expect("alarm should be claimed"); assert_eq!(claimed.context.current_alarm_id, "alarm-1"); - assert!(claimed.deleted_run_once_alarm); + assert!(claimed.deleted_one_shot_alarm); assert!(alarms.list_alarms().is_empty()); } @@ -665,9 +546,8 @@ mod tests { .create_alarm( CreateAlarm { id: "alarm-1".to_string(), - cron_expression: AFTER_TURN_CRON_EXPRESSION.to_string(), + trigger: delay(TEN_SECONDS, Some(true)), prompt: "older recurring alarm".to_string(), - run_once: false, delivery: AlarmDelivery::AfterTurn, now: create_first, }, @@ -678,15 +558,16 @@ mod tests { .create_alarm( CreateAlarm { id: "alarm-2".to_string(), - cron_expression: AFTER_TURN_CRON_EXPRESSION.to_string(), + trigger: delay(TEN_SECONDS, Some(true)), prompt: "newer recurring alarm".to_string(), - run_once: false, delivery: AlarmDelivery::AfterTurn, now: create_second, }, /*timer_cancel*/ None, ) .expect("alarm should be created"); + alarms.mark_alarm_due("alarm-1", first_claimed_at); + alarms.mark_alarm_due("alarm-2", first_claimed_at); let first = alarms .claim_next_alarm( @@ -697,8 +578,6 @@ mod tests { .expect("first alarm should be claimed"); assert_eq!(first.context.current_alarm_id, "alarm-1"); - alarms.mark_after_turn_alarms_due(); - let second = alarms .claim_next_alarm( second_claimed_at, @@ -718,9 +597,8 @@ mod tests { .create_alarm( CreateAlarm { id: format!("alarm-{index}"), - cron_expression: AFTER_TURN_CRON_EXPRESSION.to_string(), + trigger: delay(SIXTY_SECONDS, Some(true)), prompt: format!("prompt-{index}"), - run_once: false, delivery: AlarmDelivery::AfterTurn, now, }, @@ -732,9 +610,8 @@ mod tests { let result = alarms.create_alarm( CreateAlarm { id: "alarm-overflow".to_string(), - cron_expression: AFTER_TURN_CRON_EXPRESSION.to_string(), + trigger: delay(SIXTY_SECONDS, Some(true)), prompt: "overflow".to_string(), - run_once: false, delivery: AlarmDelivery::AfterTurn, now, }, @@ -753,9 +630,9 @@ mod tests { fn alarm_prompt_input_is_hidden_developer_input() { let item = alarm_prompt_input_item(&AlarmInvocationContext { current_alarm_id: "alarm-1".to_string(), - cron_expression: "@every 10s".to_string(), + trigger: delay(TEN_SECONDS, Some(true)), prompt: "run tests".to_string(), - run_once: false, + recurring: true, delivery: AlarmDelivery::SteerCurrentTurn, }); assert_eq!( @@ -763,7 +640,7 @@ mod tests { ResponseInputItem::Message { role: "developer".to_string(), content: vec![ContentItem::InputText { - text: "Recurring scheduled alarm prompt:\nrun tests\n\ncurrentAlarmId: alarm-1\nConfigured delivery: steer-current-turn\nSchedule: @every 10s\n\nThis alarm should keep running on its schedule unless the user asked for a stopping condition and that condition is now satisfied.\nIf that stopping condition is satisfied, stop the alarm by calling AlarmDelete with {\"id\":\"alarm-1\"}.\nDo not expose scheduler internals unless they matter to the user.".to_string(), + text: "Recurring scheduled alarm prompt:\nrun tests\n\ncurrentAlarmId: alarm-1\nConfigured delivery: steer-current-turn\nTrigger: delay 10s, repeat\n\nThis alarm should keep running on its schedule unless the user asked for a stopping condition and that condition is now satisfied.\nIf that stopping condition is satisfied, stop the alarm by calling AlarmDelete with {\"id\":\"alarm-1\"}.\nDo not expose scheduler internals unless they matter to the user.".to_string(), }], } ); @@ -773,9 +650,9 @@ mod tests { fn one_shot_alarm_prompt_input_omits_delete_instruction() { let item = alarm_prompt_input_item(&AlarmInvocationContext { current_alarm_id: "alarm-1".to_string(), - cron_expression: "@after-turn".to_string(), + trigger: delay(ZERO_SECONDS, /*repeat*/ None), prompt: "run tests once".to_string(), - run_once: true, + recurring: false, delivery: AlarmDelivery::AfterTurn, }); assert_eq!( @@ -783,7 +660,7 @@ mod tests { ResponseInputItem::Message { role: "developer".to_string(), content: vec![ContentItem::InputText { - text: "One-shot scheduled alarm prompt:\nrun tests once\n\ncurrentAlarmId: alarm-1\nConfigured delivery: after-turn\nSchedule: @after-turn\n\nThis one-shot alarm has already been removed from the schedule, so you do not need to call AlarmDelete.\nDo not expose scheduler internals unless they matter to the user.".to_string(), + text: "One-shot scheduled alarm prompt:\nrun tests once\n\ncurrentAlarmId: alarm-1\nConfigured delivery: after-turn\nTrigger: delay 0s\n\nThis one-shot alarm has already been removed from the schedule, so you do not need to call AlarmDelete.\nDo not expose scheduler internals unless they matter to the user.".to_string(), }], } ); @@ -796,9 +673,8 @@ mod tests { let persisted = vec![PersistedAlarm { alarm: super::ThreadAlarm { id: "alarm-1".to_string(), - cron_expression: "@after-turn".to_string(), + trigger: delay(ZERO_SECONDS, /*repeat*/ None), prompt: "run tests".to_string(), - run_once: false, delivery: AlarmDelivery::AfterTurn, created_at: 1, next_run_at: None, @@ -828,9 +704,8 @@ mod tests { let original = vec![PersistedAlarm { alarm: super::ThreadAlarm { id: "alarm-1".to_string(), - cron_expression: "@after-turn".to_string(), + trigger: delay(ZERO_SECONDS, /*repeat*/ None), prompt: "run tests".to_string(), - run_once: false, delivery: AlarmDelivery::AfterTurn, created_at: 1, next_run_at: None, @@ -841,9 +716,8 @@ mod tests { let replacement = vec![PersistedAlarm { alarm: super::ThreadAlarm { id: "alarm-2".to_string(), - cron_expression: "@after-turn".to_string(), + trigger: delay(SIXTY_SECONDS, Some(true)), prompt: "run different tests".to_string(), - run_once: true, delivery: AlarmDelivery::SteerCurrentTurn, created_at: 2, next_run_at: None, @@ -864,4 +738,51 @@ mod tests { .expect("load overwritten sidecar"); assert_eq!(loaded, replacement); } + + #[tokio::test] + async fn alarm_sidecar_skips_invalid_entries() { + let tempdir = TempDir::new().expect("tempdir"); + let rollout_path = tempdir.path().join("rollout.jsonl"); + let sidecar_path = alarm_sidecar_path_for_rollout(&rollout_path); + tokio::fs::write( + &sidecar_path, + r#"[ + { "alarm": { "id": "old", "cronExpression": "@after-turn" }, "pending_run": true }, + { + "alarm": { + "id": "alarm-1", + "trigger": { "kind": "delay", "seconds": 0, "repeat": null }, + "prompt": "run tests", + "delivery": "after-turn", + "created_at": 1, + "next_run_at": null, + "last_run_at": null + }, + "pending_run": true + } + ]"#, + ) + .await + .expect("write sidecar"); + + let loaded = load_alarm_sidecar(&rollout_path) + .await + .expect("load sidecar"); + + assert_eq!( + loaded, + vec![PersistedAlarm { + alarm: super::ThreadAlarm { + id: "alarm-1".to_string(), + trigger: delay(ZERO_SECONDS, /*repeat*/ None), + prompt: "run tests".to_string(), + delivery: AlarmDelivery::AfterTurn, + created_at: 1, + next_run_at: None, + last_run_at: None, + }, + pending_run: true, + }] + ); + } } diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 1af3814203..4e005b789a 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -259,13 +259,13 @@ use crate::SkillsManager; use crate::alarms::ALARM_FIRED_BACKGROUND_EVENT_PREFIX; use crate::alarms::ALARM_UPDATED_BACKGROUND_EVENT_PREFIX; use crate::alarms::AlarmDelivery; -use crate::alarms::AlarmSchedule; use crate::alarms::AlarmTimerSpec; use crate::alarms::AlarmsState; use crate::alarms::ClaimedAlarm; use crate::alarms::CreateAlarm; use crate::alarms::PersistedAlarm; use crate::alarms::ThreadAlarm; +use crate::alarms::ThreadAlarmTrigger; use crate::alarms::alarm_prompt_input_item; use crate::alarms::load_alarm_sidecar; use crate::alarms::write_alarm_sidecar; @@ -2700,9 +2700,8 @@ impl Session { pub(crate) async fn create_alarm( self: &Arc, - cron_expression: String, + trigger: ThreadAlarmTrigger, prompt: String, - run_once: bool, delivery: AlarmDelivery, ) -> Result { self.ensure_rollout_materialized().await; @@ -2710,22 +2709,19 @@ impl Session { return Err("alarms require a persisted rollout path for restoration".to_string()); }; - let schedule = AlarmSchedule::parse(&cron_expression)?; - let timer_cancel = - matches!(schedule, AlarmSchedule::EverySeconds(_)).then(CancellationToken::new); + let timer_cancel = CancellationToken::new(); let id = uuid::Uuid::new_v4().to_string(); - let alarm = { + let (alarm, timer_spec) = { let mut alarms = self.alarms.lock().await; alarms.create_alarm( CreateAlarm { id: id.clone(), - cron_expression, + trigger, prompt, - run_once, delivery, now: Utc::now(), }, - timer_cancel.clone(), + Some(timer_cancel.clone()), )? }; if let Err(err) = self.persist_alarms_to_rollout_sidecar(&rollout_path).await { @@ -2735,15 +2731,8 @@ impl Session { return Err(err); } - if let (AlarmSchedule::EverySeconds(seconds), Some(cancel)) = (schedule, timer_cancel) { - self.spawn_alarm_timer( - id, - AlarmTimerSpec { - seconds, - initial_delay: std::time::Duration::from_secs(seconds), - }, - cancel, - ); + if let Some(timer_spec) = timer_spec { + self.spawn_alarm_timer(id, timer_spec, timer_cancel); } self.emit_alarm_updated_notification().await; self.maybe_start_pending_alarm().await; @@ -2767,25 +2756,18 @@ impl Session { Ok(true) } - pub(crate) async fn mark_after_turn_alarms_due(&self) { - let changed = self.alarms.lock().await.mark_after_turn_alarms_due(); - if changed { - self.persist_alarms_sidecar_best_effort().await; - } - } - pub(crate) async fn maybe_start_pending_alarm(self: &Arc) { let Some(ClaimedAlarm { alarm, context, - deleted_run_once_alarm, + deleted_one_shot_alarm, }) = self.claim_next_alarm_for_delivery().await else { return; }; self.emit_alarm_fired_notification(&alarm).await; - if deleted_run_once_alarm { + if deleted_one_shot_alarm { self.emit_alarm_updated_notification().await; } self.persist_alarms_sidecar_best_effort().await; @@ -2869,7 +2851,7 @@ impl Session { let weak = Arc::downgrade(self); let session_cancel = self.alarm_timers_cancellation_token.clone(); tokio::spawn(async move { - let mut delay = timer_spec.initial_delay; + let mut delay = timer_spec.delay; loop { tokio::select! { _ = session_cancel.cancelled() => break, @@ -2884,7 +2866,15 @@ impl Session { session.persist_alarms_sidecar_best_effort().await; } session.maybe_start_pending_alarm().await; - delay = std::time::Duration::from_secs(timer_spec.seconds); + let next_timer_spec = session + .alarms + .lock() + .await + .timer_spec_for_alarm(&id, Utc::now()); + let Some(next_timer_spec) = next_timer_spec else { + break; + }; + delay = next_timer_spec.delay; } }); } @@ -2939,24 +2929,13 @@ impl Session { let mut normalized = Vec::::new(); for persisted_alarm in persisted { - let schedule = match AlarmSchedule::parse(&persisted_alarm.alarm.cron_expression) { - Ok(schedule) => schedule, - Err(err) => { - warn!( - "skipping invalid persisted alarm {}: {err}", - persisted_alarm.alarm.id - ); - continue; - } - }; - let timer_cancel = - matches!(schedule, AlarmSchedule::EverySeconds(_)).then(CancellationToken::new); + let timer_cancel = CancellationToken::new(); let timer_spec = { let mut alarms = self.alarms.lock().await; match alarms.restore_alarm( persisted_alarm.clone(), Utc::now(), - timer_cancel.clone(), + Some(timer_cancel.clone()), ) { Ok(timer_spec) => { normalized = alarms.persisted_alarms(); @@ -2971,8 +2950,8 @@ impl Session { } } }; - if let (Some(timer_spec), Some(cancel)) = (timer_spec, timer_cancel) { - self.spawn_alarm_timer(persisted_alarm.alarm.id.clone(), timer_spec, cancel); + if let Some(timer_spec) = timer_spec { + self.spawn_alarm_timer(persisted_alarm.alarm.id.clone(), timer_spec, timer_cancel); } } diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index e73a0281e4..3e078fbc4c 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -1,6 +1,7 @@ use super::*; use crate::alarms::AlarmDelivery; use crate::alarms::AlarmsState; +use crate::alarms::ThreadAlarmTrigger; use crate::alarms::load_alarm_sidecar; use crate::alarms::write_alarm_sidecar; use crate::config::ConfigBuilder; @@ -3677,9 +3678,11 @@ async fn maybe_start_pending_alarm_claims_only_one_alarm_while_start_is_in_progr .create_alarm( crate::alarms::CreateAlarm { id: "alarm-1".to_string(), - cron_expression: crate::alarms::AFTER_TURN_CRON_EXPRESSION.to_string(), + trigger: ThreadAlarmTrigger::Delay { + seconds: 10, + repeat: Some(true), + }, prompt: "first".to_string(), - run_once: false, delivery: AlarmDelivery::AfterTurn, now, }, @@ -3690,15 +3693,19 @@ async fn maybe_start_pending_alarm_claims_only_one_alarm_while_start_is_in_progr .create_alarm( crate::alarms::CreateAlarm { id: "alarm-2".to_string(), - cron_expression: crate::alarms::AFTER_TURN_CRON_EXPRESSION.to_string(), + trigger: ThreadAlarmTrigger::Delay { + seconds: 10, + repeat: Some(true), + }, prompt: "second".to_string(), - run_once: false, delivery: AlarmDelivery::AfterTurn, now, }, /*timer_cancel*/ None, ) .expect("second alarm should be created"); + alarms.mark_alarm_due("alarm-1", now); + alarms.mark_alarm_due("alarm-2", now); } let first = Arc::clone(&session); @@ -3730,9 +3737,11 @@ async fn alarm_sidecar_persistence_serializes_stale_and_newer_snapshots() { .create_alarm( crate::alarms::CreateAlarm { id: "alarm-1".to_string(), - cron_expression: crate::alarms::AFTER_TURN_CRON_EXPRESSION.to_string(), + trigger: ThreadAlarmTrigger::Delay { + seconds: 10, + repeat: Some(true), + }, prompt: "first".to_string(), - run_once: false, delivery: AlarmDelivery::AfterTurn, now, }, @@ -3769,9 +3778,11 @@ async fn alarm_sidecar_persistence_serializes_stale_and_newer_snapshots() { .create_alarm( crate::alarms::CreateAlarm { id: "alarm-2".to_string(), - cron_expression: crate::alarms::AFTER_TURN_CRON_EXPRESSION.to_string(), + trigger: ThreadAlarmTrigger::Delay { + seconds: 10, + repeat: Some(true), + }, prompt: "second".to_string(), - run_once: false, delivery: AlarmDelivery::AfterTurn, now, }, diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index ecee17b24b..bd03bd8bf2 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -28,6 +28,7 @@ use tokio::sync::watch; use crate::alarms::AlarmDelivery; use crate::alarms::ThreadAlarm; +use crate::alarms::ThreadAlarmTrigger; use codex_rollout::state_db::StateDbHandle; #[derive(Clone, Debug)] @@ -242,14 +243,13 @@ impl CodexThread { pub async fn create_alarm( &self, - cron_expression: String, + trigger: ThreadAlarmTrigger, prompt: String, - run_once: bool, delivery: AlarmDelivery, ) -> Result { self.codex .session - .create_alarm(cron_expression, prompt, run_once, delivery) + .create_alarm(trigger, prompt, delivery) .await } diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 021c2cee4f..008dfcf4d3 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -28,6 +28,7 @@ pub mod connectors; mod context_manager; mod contextual_user_message; pub use codex_utils_path::env; +mod alarm_trigger; pub mod alarms; mod environment_context; pub mod exec; diff --git a/codex-rs/core/src/shell_snapshot.rs b/codex-rs/core/src/shell_snapshot.rs index 4169406335..29b50cb9e8 100644 --- a/codex-rs/core/src/shell_snapshot.rs +++ b/codex-rs/core/src/shell_snapshot.rs @@ -29,7 +29,7 @@ pub struct ShellSnapshot { pub cwd: PathBuf, } -const SNAPSHOT_TIMEOUT: Duration = Duration::from_secs(20); +const SNAPSHOT_TIMEOUT: Duration = Duration::from_secs(10); const SNAPSHOT_RETENTION: Duration = Duration::from_secs(60 * 60 * 24 * 3); // 3 days retention. const SNAPSHOT_DIR: &str = "shell_snapshots"; const EXCLUDED_EXPORT_VARS: &[&str] = &["PWD", "OLDPWD"]; diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index 1bfd155477..426f3e528a 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -519,7 +519,6 @@ impl Session { let session = Arc::clone(self); let _scheduler = tokio::task::spawn_blocking(move || { tokio::runtime::Handle::current().block_on(async move { - session.mark_after_turn_alarms_due().await; session.maybe_start_pending_alarm().await; if should_clear_active_turn { session.maybe_start_turn_for_pending_work().await; diff --git a/codex-rs/core/src/tools/handlers/alarms.rs b/codex-rs/core/src/tools/handlers/alarms.rs index 907cde4a6a..c538efccfd 100644 --- a/codex-rs/core/src/tools/handlers/alarms.rs +++ b/codex-rs/core/src/tools/handlers/alarms.rs @@ -6,6 +6,7 @@ use serde::Deserialize; use crate::alarms::AlarmDelivery; +use crate::alarms::ThreadAlarmTrigger; use crate::function_tool::FunctionCallError; use crate::tools::context::FunctionToolOutput; use crate::tools::context::ToolInvocation; @@ -16,9 +17,8 @@ use crate::tools::registry::ToolKind; #[derive(Deserialize)] struct AlarmCreateArgs { - cron_expression: String, + trigger: ThreadAlarmTrigger, prompt: String, - run_once: Option, delivery: AlarmDelivery, } @@ -45,12 +45,7 @@ impl ToolHandler for AlarmCreateHandler { let args: AlarmCreateArgs = parse_arguments(&arguments)?; let alarm = invocation .session - .create_alarm( - args.cron_expression, - args.prompt, - args.run_once.unwrap_or(false), - args.delivery, - ) + .create_alarm(args.trigger, args.prompt, args.delivery) .await .map_err(FunctionCallError::RespondToModel)?; let content = serde_json::to_string(&alarm).map_err(|err| { diff --git a/codex-rs/core/templates/alarms/one_shot_prompt.md b/codex-rs/core/templates/alarms/one_shot_prompt.md index 1682ef58f0..4267cc3aa7 100644 --- a/codex-rs/core/templates/alarms/one_shot_prompt.md +++ b/codex-rs/core/templates/alarms/one_shot_prompt.md @@ -3,7 +3,7 @@ One-shot scheduled alarm prompt: currentAlarmId: {{CURRENT_ALARM_ID}} Configured delivery: {{DELIVERY}} -Schedule: {{SCHEDULE}} +Trigger: {{TRIGGER}} This one-shot alarm has already been removed from the schedule, so you do not need to call AlarmDelete. Do not expose scheduler internals unless they matter to the user. diff --git a/codex-rs/core/templates/alarms/recurring_prompt.md b/codex-rs/core/templates/alarms/recurring_prompt.md index 7654603fcf..4660da9cba 100644 --- a/codex-rs/core/templates/alarms/recurring_prompt.md +++ b/codex-rs/core/templates/alarms/recurring_prompt.md @@ -3,7 +3,7 @@ Recurring scheduled alarm prompt: currentAlarmId: {{CURRENT_ALARM_ID}} Configured delivery: {{DELIVERY}} -Schedule: {{SCHEDULE}} +Trigger: {{TRIGGER}} This alarm should keep running on its schedule unless the user asked for a stopping condition and that condition is now satisfied. If that stopping condition is satisfied, stop the alarm by calling AlarmDelete with {"id":"{{CURRENT_ALARM_ID}}"}. diff --git a/codex-rs/core/tests/suite/alarms.rs b/codex-rs/core/tests/suite/alarms.rs index a244e0ffd5..8d71f78443 100644 --- a/codex-rs/core/tests/suite/alarms.rs +++ b/codex-rs/core/tests/suite/alarms.rs @@ -1,9 +1,9 @@ use anyhow::Result; use anyhow::anyhow; -use codex_core::alarms::AFTER_TURN_CRON_EXPRESSION; use codex_core::alarms::ALARM_FIRED_BACKGROUND_EVENT_PREFIX; use codex_core::alarms::AlarmDelivery; use codex_core::alarms::ThreadAlarm; +use codex_core::alarms::ThreadAlarmTrigger; use codex_features::Feature; use codex_protocol::protocol::EventMsg; use core_test_support::responses::ev_assistant_message; @@ -50,9 +50,11 @@ async fn assert_after_turn_alarm_starts_and_emits_fired_event() -> Result<()> { let created = test .codex .create_alarm( - AFTER_TURN_CRON_EXPRESSION.to_string(), + ThreadAlarmTrigger::Delay { + seconds: 0, + repeat: None, + }, "run alarm".to_string(), - /*run_once*/ false, AlarmDelivery::AfterTurn, ) .await @@ -67,15 +69,7 @@ async fn assert_after_turn_alarm_starts_and_emits_fired_event() -> Result<()> { }) .await; let fired: ThreadAlarm = serde_json::from_str(&payload)?; - let expected = ThreadAlarm { - last_run_at: Some( - fired - .last_run_at - .ok_or_else(|| anyhow!("claimed alarm should record last_run_at"))?, - ), - ..created - }; - assert_eq!(fired, expected); + assert_eq!(fired, created); wait_for_event(&test.codex, |event| { matches!(event, EventMsg::TurnComplete(_)) diff --git a/codex-rs/rollout/src/recorder_tests.rs b/codex-rs/rollout/src/recorder_tests.rs index 9a597257d2..0dbac0e51e 100644 --- a/codex-rs/rollout/src/recorder_tests.rs +++ b/codex-rs/rollout/src/recorder_tests.rs @@ -456,65 +456,6 @@ async fn list_threads_db_enabled_repairs_stale_rollout_paths() -> std::io::Resul Ok(()) } -#[tokio::test] -async fn list_threads_includes_alarm_only_sessions_without_user_messages() -> std::io::Result<()> { - let home = TempDir::new().expect("temp dir"); - let config = test_config(home.path()); - - let uuid = Uuid::from_u128(9014); - let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); - let path = write_session_file_without_user_message(home.path(), "2025-01-03T14-00-00", uuid)?; - fs::write(format!("{}.alarms.json", path.display()), "[]")?; - - let runtime = codex_state::StateRuntime::init( - home.path().to_path_buf(), - config.model_provider_id.clone(), - ) - .await - .expect("state db should initialize"); - runtime - .mark_backfill_complete(/*last_watermark*/ None) - .await - .expect("backfill should be complete"); - let created_at = chrono::Utc - .with_ymd_and_hms(2025, 1, 3, 14, 0, 0) - .single() - .expect("valid datetime"); - let mut builder = codex_state::ThreadMetadataBuilder::new( - thread_id, - path.clone(), - created_at, - SessionSource::Cli, - ); - builder.model_provider = Some(config.model_provider_id.clone()); - builder.cwd = home.path().to_path_buf(); - let metadata = builder.build(config.model_provider_id.as_str()); - runtime - .upsert_thread(&metadata) - .await - .expect("state db upsert should succeed"); - - let default_provider = config.model_provider_id.clone(); - let page = RolloutRecorder::list_threads( - &config, - /*page_size*/ 1, - /*cursor*/ None, - ThreadSortKey::CreatedAt, - &[], - /*model_providers*/ None, - default_provider.as_str(), - /*search_term*/ None, - ) - .await?; - assert_eq!(page.items.len(), 1); - assert_eq!(page.items[0].path, path); - assert_eq!( - page.items[0].first_user_message.as_deref(), - Some("(alarm configured)") - ); - Ok(()) -} - #[tokio::test] async fn list_threads_db_enabled_fills_page_after_filtering_empty_threads() -> std::io::Result<()> { let home = TempDir::new().expect("temp dir"); diff --git a/codex-rs/rollout/src/state_db.rs b/codex-rs/rollout/src/state_db.rs index f95525c928..6367a27ca9 100644 --- a/codex-rs/rollout/src/state_db.rs +++ b/codex-rs/rollout/src/state_db.rs @@ -1,4 +1,3 @@ -use crate::alarm_sidecar::thread_preview_from_alarm_sidecar; use crate::config::RolloutConfig; use crate::config::RolloutConfigView; use crate::list::Cursor; @@ -227,81 +226,47 @@ pub async fn list_threads_db( }) .collect(); let model_providers = model_providers.map(<[String]>::to_vec); - let db_sort_key = match sort_key { - ThreadSortKey::CreatedAt => codex_state::SortKey::CreatedAt, - ThreadSortKey::UpdatedAt => codex_state::SortKey::UpdatedAt, - }; - let mut db_anchor = anchor; - let mut valid_items = Vec::with_capacity(page_size); - let mut next_anchor = None; - let mut num_scanned_rows = 0; - loop { - let remaining = page_size.saturating_sub(valid_items.len()); - if remaining == 0 { - break; - } - let mut page = match ctx - .list_threads( - remaining, - db_anchor.as_ref(), - db_sort_key, - allowed_sources.as_slice(), - model_providers.as_deref(), - archived, - search_term, - ) - .await - { - Ok(page) => page, - Err(err) => { - warn!("state db list_threads failed: {err}"); - return None; - } - }; - num_scanned_rows += page.num_scanned_rows; - let page_next_anchor = page.next_anchor.take(); - for mut item in page.items { - if tokio::fs::try_exists(&item.rollout_path) - .await - .unwrap_or(false) - { - let missing_preview = item.first_user_message.as_deref().is_none_or(str::is_empty); - if missing_preview { - if let Some(alarm_preview) = - thread_preview_from_alarm_sidecar(&item.rollout_path).await - { - item.first_user_message = Some(alarm_preview); - } else { - continue; - } + match ctx + .list_threads( + page_size, + anchor.as_ref(), + match sort_key { + ThreadSortKey::CreatedAt => codex_state::SortKey::CreatedAt, + ThreadSortKey::UpdatedAt => codex_state::SortKey::UpdatedAt, + }, + allowed_sources.as_slice(), + model_providers.as_deref(), + archived, + search_term, + ) + .await + { + Ok(mut page) => { + let mut valid_items = Vec::with_capacity(page.items.len()); + for item in page.items { + if tokio::fs::try_exists(&item.rollout_path) + .await + .unwrap_or(false) + { + valid_items.push(item); + } else { + warn!( + "state db list_threads returned stale rollout path for thread {}: {}", + item.id, + item.rollout_path.display() + ); + warn!("state db discrepancy during list_threads_db: stale_db_path_dropped"); + let _ = ctx.delete_thread(item.id).await; } - valid_items.push(item); - } else { - warn!( - "state db list_threads returned stale rollout path for thread {}: {}", - item.id, - item.rollout_path.display() - ); - warn!("state db discrepancy during list_threads_db: stale_db_path_dropped"); - let _ = ctx.delete_thread(item.id).await; } + page.items = valid_items; + Some(page) } - - if valid_items.len() == page_size { - next_anchor = page_next_anchor; - break; - } - - match page_next_anchor { - Some(anchor) => db_anchor = Some(anchor), - None => break, + Err(err) => { + warn!("state db list_threads failed: {err}"); + None } } - Some(codex_state::ThreadsPage { - items: valid_items, - next_anchor, - num_scanned_rows, - }) } /// Look up the rollout path for a thread id using SQLite. diff --git a/codex-rs/state/src/runtime/threads.rs b/codex-rs/state/src/runtime/threads.rs index 35bb1b0d41..09b23a4319 100644 --- a/codex-rs/state/src/runtime/threads.rs +++ b/codex-rs/state/src/runtime/threads.rs @@ -883,6 +883,7 @@ pub(super) fn push_thread_filters<'a>( } else { builder.push(" AND archived = 0"); } + builder.push(" AND first_user_message <> ''"); if !allowed_sources.is_empty() { builder.push(" AND source IN ("); let mut separated = builder.separated(", "); diff --git a/codex-rs/tools/src/alarm_tool.rs b/codex-rs/tools/src/alarm_tool.rs index 33de83be95..4dd24a5b68 100644 --- a/codex-rs/tools/src/alarm_tool.rs +++ b/codex-rs/tools/src/alarm_tool.rs @@ -10,31 +10,56 @@ use crate::ToolSpec; use std::collections::BTreeMap; pub fn create_alarm_create_tool() -> ToolSpec { - let properties = BTreeMap::from([ + let trigger_properties = BTreeMap::from([ ( - "cron_expression".to_string(), + "kind".to_string(), + JsonSchema::String { + description: Some("Trigger kind. Use `delay` or `schedule`.".to_string()), + }, + ), + ( + "seconds".to_string(), + JsonSchema::Number { + description: Some("Delay trigger seconds from creation time.".to_string()), + }, + ), + ( + "repeat".to_string(), + JsonSchema::Boolean { + description: Some("Delay trigger recurrence flag.".to_string()), + }, + ), + ( + "dtstart".to_string(), JsonSchema::String { description: Some( - "Scheduler expression for the alarm. Supported values are scheduler-specific." + "Schedule trigger floating local datetime in YYYY-MM-DDTHH:MM:SS format." .to_string(), ), }, ), + ( + "rrule".to_string(), + JsonSchema::String { + description: Some("Schedule trigger RRULE string.".to_string()), + }, + ), + ]); + let properties = BTreeMap::from([ + ( + "trigger".to_string(), + JsonSchema::Object { + properties: trigger_properties, + required: Some(vec!["kind".to_string()]), + additional_properties: Some(false.into()), + }, + ), ( "prompt".to_string(), JsonSchema::String { description: Some("Prompt to execute when the alarm fires.".to_string()), }, ), - ( - "run_once".to_string(), - JsonSchema::Boolean { - description: Some( - "Optional. When true, delete the alarm after its next execution is claimed." - .to_string(), - ), - }, - ), ( "delivery".to_string(), JsonSchema::String { @@ -48,15 +73,14 @@ pub fn create_alarm_create_tool() -> ToolSpec { ToolSpec::Function(ResponsesApiTool { name: "AlarmCreate".to_string(), - description: - "Create a thread alarm using a structured scheduler expression, prompt, and delivery mode." - .to_string(), + description: "Create a thread alarm using a structured trigger, prompt, and delivery mode." + .to_string(), strict: false, defer_loading: None, parameters: JsonSchema::Object { properties, required: Some(vec![ - "cron_expression".to_string(), + "trigger".to_string(), "prompt".to_string(), "delivery".to_string(), ]), diff --git a/codex-rs/tui/src/alarm_scheduler.rs b/codex-rs/tui/src/alarm_scheduler.rs index 42c7a58d9b..783858a4a5 100644 --- a/codex-rs/tui/src/alarm_scheduler.rs +++ b/codex-rs/tui/src/alarm_scheduler.rs @@ -1,151 +1,121 @@ +use crate::AppServerTarget; +use crate::start_app_server_for_picker; +use codex_app_server_client::AppServerEvent; use codex_app_server_protocol::AlarmDelivery; +use codex_app_server_protocol::AlarmTrigger; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::TurnStatus; use codex_core::config::Config; -use regex_lite::Regex; +use codex_protocol::models::ContentItem; +use codex_protocol::models::ResponseItem; +use codex_protocol::user_input::UserInput; use serde::Deserialize; -use std::sync::LazyLock; +use serde_json::Value; +use serde_json::json; +use std::time::Duration; -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] pub(crate) struct ParsedAlarmSpec { - pub(crate) cron_expression: String, + pub(crate) trigger: AlarmTrigger, pub(crate) prompt: String, - pub(crate) run_once: Option, pub(crate) delivery: AlarmDelivery, } -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct ParsedAlarmSpecResponse { - cron_expression: String, - prompt: String, - run_once: Option, - delivery: AlarmDelivery, -} - -impl ParsedAlarmSpecResponse { - fn into_parsed(self) -> std::result::Result { - let cron_expression = self.cron_expression.trim().to_string(); - let prompt = self.prompt.trim().to_string(); - if cron_expression.is_empty() { - return Err("Could not determine a supported schedule from /loop input.".to_string()); - } - if prompt.is_empty() { - return Err("Could not determine a prompt from /loop input.".to_string()); - } - Ok(ParsedAlarmSpec { - cron_expression, - prompt, - run_once: self.run_once, - delivery: self.delivery, - }) - } -} - -static EVERY_PATTERN: LazyLock = LazyLock::new(|| { - Regex::new(r"(?i)\bevery\s+(\d+)\s*(seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h|days?|d)\b") - .unwrap_or_else(|err| panic!("valid recurring alarm parser regex: {err}")) -}); -static RUN_ONCE_PATTERN: LazyLock = LazyLock::new(|| { - Regex::new(r"(?i)\b(run once|once|one-shot|one shot|single-run|single run|just once)\b") - .unwrap_or_else(|err| panic!("valid one-shot alarm parser regex: {err}")) -}); -static STEER_CURRENT_TURN_PATTERN: LazyLock = LazyLock::new(|| { - Regex::new( - r"(?i)\b(as a steer|steer (?:the )?current turn|steer this turn|during (?:the )?current turn|during this turn|in (?:the )?current turn)\b", - ) - .unwrap_or_else(|err| panic!("valid steer alarm parser regex: {err}")) -}); - pub(crate) async fn parse_alarm_spec( - _config: Config, - _target: crate::AppServerTarget, + config: Config, + target: AppServerTarget, spec: String, ) -> std::result::Result { - parse_alarm_spec_text(&spec) -} - -fn parse_alarm_spec_text(spec: &str) -> std::result::Result { - let trimmed = spec.trim(); - if trimmed.is_empty() { + if spec.trim().is_empty() { return Err("Could not determine a prompt from /loop input.".to_string()); } - - let delivery = if STEER_CURRENT_TURN_PATTERN.is_match(trimmed) { - AlarmDelivery::SteerCurrentTurn - } else { - AlarmDelivery::AfterTurn - }; - let run_once = RUN_ONCE_PATTERN.is_match(trimmed).then_some(true); - - let (cron_expression, prompt) = if let Some(captures) = EVERY_PATTERN.captures(trimmed) { - let quantity = captures.get(1).map(|m| m.as_str()).ok_or_else(|| { - "Could not determine a supported schedule from /loop input.".to_string() - })?; - let unit = captures.get(2).map(|m| m.as_str()).ok_or_else(|| { - "Could not determine a supported schedule from /loop input.".to_string() - })?; - let schedule = captures.get(0).ok_or_else(|| { - "Could not determine a supported schedule from /loop input.".to_string() - })?; - let prompt = remove_match(trimmed, schedule.start(), schedule.end()); - ( - format!("@every {quantity}{}", normalized_unit_suffix(unit)?), - clean_prompt_text(&prompt), + let user_prompt = build_user_prompt(&spec); + let mut app_server = start_app_server_for_picker(&config, &target) + .await + .map_err(|err| format!("failed to start alarm spec parser: {err}"))?; + let started = app_server + .start_ephemeral_thread_with_base_instructions( + &config, + PARSE_ALARM_SYSTEM_PROMPT.to_string(), ) - } else { - ("@after-turn".to_string(), trimmed.to_string()) - }; - - ParsedAlarmSpecResponse { - cron_expression, - prompt, - run_once, - delivery, - } - .into_parsed() -} - -fn normalized_unit_suffix(unit: &str) -> std::result::Result { - match unit.to_ascii_lowercase().as_str() { - "s" | "second" | "seconds" | "sec" | "secs" => Ok('s'), - "m" | "minute" | "minutes" | "min" | "mins" => Ok('m'), - "h" | "hour" | "hours" | "hr" | "hrs" => Ok('h'), - "d" | "day" | "days" => Ok('d'), - _ => Err("Could not determine a supported schedule from /loop input.".to_string()), - } -} - -fn remove_match(text: &str, start: usize, end: usize) -> String { - let prefix = text[..start].trim_end(); - let suffix = text[end..].trim_start_matches([' ', ',', ':', ';', '-']); - match (prefix.is_empty(), suffix.is_empty()) { - (true, true) => String::new(), - (true, false) => suffix.to_string(), - (false, true) => prefix.to_string(), - (false, false) => format!("{prefix} {suffix}"), - } -} - -fn clean_prompt_text(prompt: &str) -> String { - prompt - .trim() - .trim_start_matches([',', ':', ';', '-']) - .trim() - .to_string() + .await + .map_err(|err| format!("failed to start alarm spec parser thread: {err}"))?; + let thread_id = started.session.thread_id; + let thread_id_string = thread_id.to_string(); + let response = app_server + .turn_start( + thread_id, + vec![UserInput::Text { + text: user_prompt, + text_elements: Vec::new(), + }], + started.session.cwd, + started.session.approval_policy, + started.session.approvals_reviewer, + started.session.sandbox_policy, + started.session.model, + started.session.reasoning_effort, + config.model_reasoning_summary, + Some(config.service_tier), + /*collaboration_mode*/ None, + config.personality, + Some(output_schema()), + ) + .await + .map_err(|err| format!("failed to parse alarm spec with model: {err}"))?; + let turn_id = response.turn.id; + let result = wait_for_parser_response(&mut app_server, thread_id_string, turn_id).await?; + let parsed: ParsedAlarmSpec = serde_json::from_str(&result) + .map_err(|err| format!("model returned invalid alarm parse output: {err}"))?; + validate_parsed_alarm_spec(parsed) } pub(crate) fn format_alarm_summary( - cron_expression: &str, - run_once: bool, + trigger: &AlarmTrigger, delivery: AlarmDelivery, prompt: &str, ) -> String { - let mode = if run_once { "one-shot" } else { "recurring" }; + let mode = if trigger_is_recurring(trigger) { + "recurring" + } else { + "one-shot" + }; format!( - "{cron_expression} ({mode}, {}) -> {prompt}", + "{} ({mode}, {}) -> {prompt}", + format_alarm_trigger(trigger), delivery_str(delivery) ) } +pub(crate) fn format_alarm_trigger(trigger: &AlarmTrigger) -> String { + match trigger { + AlarmTrigger::Delay { seconds, repeat } => { + let suffix = if repeat.unwrap_or(false) { + ", repeat" + } else { + "" + }; + format!("delay {seconds}s{suffix}") + } + AlarmTrigger::Schedule { dtstart, rrule } => match (dtstart, rrule) { + (Some(dtstart), Some(rrule)) => format!("schedule {dtstart}; {rrule}"), + (Some(dtstart), None) => format!("schedule {dtstart}"), + (None, Some(rrule)) => format!("schedule {rrule}"), + (None, None) => "invalid schedule".to_string(), + }, + } +} + +pub(crate) fn trigger_is_recurring(trigger: &AlarmTrigger) -> bool { + match trigger { + AlarmTrigger::Delay { repeat, .. } => repeat.unwrap_or(false), + AlarmTrigger::Schedule { rrule, .. } => { + rrule.as_ref().is_some_and(|rrule| !rrule.is_empty()) + } + } +} + fn delivery_str(delivery: AlarmDelivery) -> &'static str { match delivery { AlarmDelivery::AfterTurn => "after-turn", @@ -153,57 +123,224 @@ fn delivery_str(delivery: AlarmDelivery) -> &'static str { } } +fn build_user_prompt(spec: &str) -> String { + let now = chrono::Local::now().format("%Y-%m-%dT%H:%M:%S"); + let timezone = chrono::Local::now().offset().to_string(); + format!("Current local datetime: {now}\nTimezone: {timezone}\nAlarm spec: {spec}") +} + +async fn wait_for_parser_response( + app_server: &mut crate::app_server_session::AppServerSession, + thread_id: String, + turn_id: String, +) -> std::result::Result { + let mut last_agent_message = None; + loop { + let event = + tokio::time::timeout(Duration::from_secs(/*secs*/ 120), app_server.next_event()) + .await + .map_err(|_| "timed out while waiting for alarm spec parser".to_string())? + .ok_or_else(|| { + "alarm spec parser disconnected before returning output".to_string() + })?; + match event { + AppServerEvent::ServerNotification(ServerNotification::ItemCompleted(notification)) + if notification.thread_id == thread_id && notification.turn_id == turn_id => + { + if let Some(text) = thread_item_agent_text(¬ification.item) { + last_agent_message = Some(text); + } + } + AppServerEvent::ServerNotification(ServerNotification::RawResponseItemCompleted( + notification, + )) if notification.thread_id == thread_id && notification.turn_id == turn_id => { + if let Some(text) = response_item_agent_text(¬ification.item) { + last_agent_message = Some(text); + } + } + AppServerEvent::ServerNotification(ServerNotification::TurnCompleted(notification)) + if notification.thread_id == thread_id && notification.turn.id == turn_id => + { + if matches!(notification.turn.status, TurnStatus::Failed) + && let Some(error) = notification.turn.error + { + return Err(format!("alarm spec parser failed: {}", error.message)); + } + return last_agent_message.ok_or_else(|| { + "alarm spec parser did not return an agent message".to_string() + }); + } + AppServerEvent::ServerNotification(_) | AppServerEvent::Lagged { .. } => {} + AppServerEvent::ServerRequest(_) => { + return Err("alarm spec parser unexpectedly requested user input".to_string()); + } + AppServerEvent::Disconnected { message } => { + return Err(format!("alarm spec parser disconnected: {message}")); + } + } + } +} + +fn thread_item_agent_text(item: &ThreadItem) -> Option { + match item { + ThreadItem::AgentMessage { text, .. } if !text.trim().is_empty() => Some(text.clone()), + ThreadItem::AgentMessage { .. } + | ThreadItem::UserMessage { .. } + | ThreadItem::Reasoning { .. } + | ThreadItem::Plan { .. } + | ThreadItem::McpToolCall { .. } + | ThreadItem::WebSearch { .. } + | ThreadItem::DynamicToolCall { .. } + | ThreadItem::CommandExecution { .. } + | ThreadItem::FileChange { .. } + | ThreadItem::ImageView { .. } + | ThreadItem::ImageGeneration { .. } + | ThreadItem::HookPrompt { .. } + | ThreadItem::CollabAgentToolCall { .. } + | ThreadItem::EnteredReviewMode { .. } + | ThreadItem::ExitedReviewMode { .. } + | ThreadItem::ContextCompaction { .. } => None, + } +} + +fn response_item_agent_text(item: &ResponseItem) -> Option { + match item { + ResponseItem::Message { role, content, .. } if role == "assistant" => { + let text = content + .iter() + .filter_map(|content| match content { + ContentItem::OutputText { text } => Some(text.as_str()), + ContentItem::InputText { .. } | ContentItem::InputImage { .. } => None, + }) + .collect::(); + (!text.trim().is_empty()).then_some(text) + } + ResponseItem::Message { .. } + | ResponseItem::Reasoning { .. } + | ResponseItem::LocalShellCall { .. } + | ResponseItem::FunctionCall { .. } + | ResponseItem::ToolSearchCall { .. } + | ResponseItem::FunctionCallOutput { .. } + | ResponseItem::CustomToolCall { .. } + | ResponseItem::CustomToolCallOutput { .. } + | ResponseItem::WebSearchCall { .. } + | ResponseItem::ImageGenerationCall { .. } + | ResponseItem::ToolSearchOutput { .. } + | ResponseItem::GhostSnapshot { .. } + | ResponseItem::Compaction { .. } + | ResponseItem::Other => None, + } +} + +fn validate_parsed_alarm_spec( + parsed: ParsedAlarmSpec, +) -> std::result::Result { + if parsed.prompt.trim().is_empty() { + return Err("model did not return an alarm prompt".to_string()); + } + Ok(ParsedAlarmSpec { + prompt: parsed.prompt.trim().to_string(), + ..parsed + }) +} + +fn output_schema() -> Value { + json!({ + "type": "object", + "properties": { + "trigger": { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["delay", "schedule"] }, + "seconds": { "type": ["integer", "null"], "minimum": 0 }, + "repeat": { "type": ["boolean", "null"] }, + "dtstart": { "type": ["string", "null"] }, + "rrule": { "type": ["string", "null"] } + }, + "required": ["kind", "seconds", "repeat", "dtstart", "rrule"], + "additionalProperties": false + }, + "prompt": { "type": "string" }, + "delivery": { "type": "string", "enum": ["after-turn", "steer-current-turn"] } + }, + "required": ["trigger", "prompt", "delivery"], + "additionalProperties": false + }) +} + +const PARSE_ALARM_SYSTEM_PROMPT: &str = r#"Parse Codex `/loop` alarm specs into a structured alarm definition. + +Return only the JSON object requested by the response schema. + +Rules: +- Extract the alarm prompt by removing the scheduling phrase but preserving the user's requested task. +- Use delivery "after-turn" unless the user clearly asks for same-turn/current-turn steering; then use "steer-current-turn". +- For "now", "immediately", or specs with no explicit timing, use { "kind": "delay", "seconds": 0, "repeat": false }. +- For delay triggers, set dtstart and rrule to null. +- For schedule triggers, set seconds and repeat to null. +- For relative one-shot timing like "in 30 seconds", use a delay trigger with seconds set to the relative delay and repeat false. +- For interval timing like "every 5 minutes", use a delay trigger with seconds set to the interval and repeat true. +- For one-shot wall-clock timing like "at 9pm", use a schedule trigger with dtstart set to the next matching local datetime in YYYY-MM-DDTHH:MM:SS and rrule null. +- For recurring calendar timing, use a schedule trigger with rrule set to an RFC 5545 RRULE string and dtstart set when the user supplies a start datetime; otherwise null. +- For schedule triggers, use floating local wall-clock datetimes without timezone suffixes. +"#; + #[cfg(test)] mod tests { use super::*; use pretty_assertions::assert_eq; #[test] - fn parse_alarm_spec_defaults_to_after_turn_without_interval() { + fn format_alarm_summary_renders_delay() { assert_eq!( - parse_alarm_spec_text("give me a random animal name").expect("parse succeeds"), + format_alarm_summary( + &AlarmTrigger::Delay { + seconds: 30, + repeat: Some(false), + }, + AlarmDelivery::AfterTurn, + "remind me to take a break", + ), + "delay 30s (one-shot, after-turn) -> remind me to take a break" + ); + } + + #[test] + fn parser_output_schema_avoids_unsupported_union_keywords() { + let schema = output_schema(); + assert_eq!(schema.pointer("/properties/trigger/oneOf"), None); + assert_eq!(schema.pointer("/properties/trigger/anyOf"), None); + assert_eq!( + schema.pointer("/properties/trigger/properties/kind/enum"), + Some(&json!(["delay", "schedule"])) + ); + } + + #[test] + fn parsed_alarm_spec_accepts_permissive_delay_trigger_shape() { + let parsed: ParsedAlarmSpec = serde_json::from_value(json!({ + "trigger": { + "kind": "delay", + "seconds": 10, + "repeat": false, + "dtstart": null, + "rrule": null + }, + "prompt": "tell me a joke", + "delivery": "after-turn" + })) + .expect("permissive parser schema output should deserialize"); + + assert_eq!( + parsed, ParsedAlarmSpec { - cron_expression: "@after-turn".to_string(), - prompt: "give me a random animal name".to_string(), - run_once: None, + trigger: AlarmTrigger::Delay { + seconds: 10, + repeat: Some(false), + }, + prompt: "tell me a joke".to_string(), delivery: AlarmDelivery::AfterTurn, } ); } - - #[test] - fn parse_alarm_spec_extracts_supported_recurring_interval() { - assert_eq!( - parse_alarm_spec_text("every 10 seconds give me an inspirational affirmation") - .expect("parse succeeds"), - ParsedAlarmSpec { - cron_expression: "@every 10s".to_string(), - prompt: "give me an inspirational affirmation".to_string(), - run_once: None, - delivery: AlarmDelivery::AfterTurn, - } - ); - } - - #[test] - fn parse_alarm_spec_detects_run_once_and_steer_delivery() { - assert_eq!( - parse_alarm_spec_text("once during this turn remind me to rebase") - .expect("parse succeeds"), - ParsedAlarmSpec { - cron_expression: "@after-turn".to_string(), - prompt: "once during this turn remind me to rebase".to_string(), - run_once: Some(true), - delivery: AlarmDelivery::SteerCurrentTurn, - } - ); - } - - #[test] - fn parse_alarm_spec_rejects_missing_prompt_after_schedule() { - assert_eq!( - parse_alarm_spec_text("every 10 seconds").expect_err("parse should fail"), - "Could not determine a prompt from /loop input.".to_string() - ); - } } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index ef787e5732..31a36b24fc 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -4261,21 +4261,16 @@ impl App { Ok(parsed) => match app_server .thread_alarm_create( thread_id, - parsed.cron_expression.clone(), + parsed.trigger.clone(), parsed.prompt.clone(), - parsed.run_once, parsed.delivery, ) .await { Ok(alarm) => { if self.chat_widget.thread_id() == Some(thread_id) { - let summary = format_alarm_summary( - &alarm.cron_expression, - alarm.run_once, - alarm.delivery, - &alarm.prompt, - ); + let summary = + format_alarm_summary(&alarm.trigger, alarm.delivery, &alarm.prompt); self.chat_widget.add_info_message( format!("Created thread alarm from `/loop {spec}`."), Some(summary), diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 7d1ca1f1c5..5b51234563 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -7,6 +7,7 @@ use codex_app_server_client::AppServerRequestHandle; use codex_app_server_client::TypedRequestError; use codex_app_server_protocol::Account; use codex_app_server_protocol::AlarmDelivery; +use codex_app_server_protocol::AlarmTrigger; use codex_app_server_protocol::AuthMode; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ConfigBatchWriteParams; @@ -320,6 +321,28 @@ impl AppServerSession { started_thread_from_start_response(response, config).await } + pub(crate) async fn start_ephemeral_thread_with_base_instructions( + &mut self, + config: &Config, + base_instructions: String, + ) -> Result { + let request_id = self.next_request_id(); + let mut params = thread_start_params_from_config( + config, + self.thread_params_mode(), + self.remote_cwd_override.as_deref(), + ); + params.ephemeral = Some(true); + params.persist_extended_history = false; + params.base_instructions = Some(base_instructions); + let response: ThreadStartResponse = self + .client + .request_typed(ClientRequest::ThreadStart { request_id, params }) + .await + .wrap_err("thread/start failed during alarm spec parsing")?; + started_thread_from_start_response(response, config).await + } + pub(crate) async fn resume_thread( &mut self, config: Config, @@ -421,9 +444,8 @@ impl AppServerSession { pub(crate) async fn thread_alarm_create( &mut self, thread_id: ThreadId, - cron_expression: String, + trigger: AlarmTrigger, prompt: String, - run_once: Option, delivery: AlarmDelivery, ) -> Result { let request_id = self.next_request_id(); @@ -433,9 +455,8 @@ impl AppServerSession { request_id, params: ThreadAlarmCreateParams { thread_id: thread_id.to_string(), - cron_expression, + trigger, prompt, - run_once, delivery, }, }) diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 73d45c920e..b592e5f118 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -41,6 +41,8 @@ use std::time::Instant; use url::Url; use self::realtime::PendingSteerCompareKey; +use crate::alarm_scheduler::format_alarm_trigger; +use crate::alarm_scheduler::trigger_is_recurring; use crate::app_command::AppCommand; use crate::app_event::RealtimeAudioDeviceKind; use crate::app_server_approval_conversions::network_approval_context_to_core; @@ -9952,16 +9954,11 @@ impl ChatWidget { codex_app_server_protocol::AlarmDelivery::AfterTurn => "after-turn", codex_app_server_protocol::AlarmDelivery::SteerCurrentTurn => "steer-current-turn", }; - let schedule = if alarm.run_once { - format!( - "Running thread alarm • {} • one-shot • {}", - alarm.cron_expression, delivery - ) + let trigger = format_alarm_trigger(&alarm.trigger); + let schedule = if trigger_is_recurring(&alarm.trigger) { + format!("Running thread alarm • {trigger} • {delivery}") } else { - format!( - "Running thread alarm • {} • {}", - alarm.cron_expression, delivery - ) + format!("Running thread alarm • {trigger} • one-shot • {delivery}") }; self.add_info_message(alarm.prompt, Some(schedule)); } @@ -9990,10 +9987,11 @@ impl ChatWidget { id: alarm_id.clone(), }); })]; - let name = if alarm.run_once { - format!("{} • one-shot", alarm.cron_expression) + let trigger = format_alarm_trigger(&alarm.trigger); + let name = if trigger_is_recurring(&alarm.trigger) { + trigger } else { - alarm.cron_expression.clone() + format!("{trigger} • one-shot") }; let selected_description = format!("{}\n\nPress Enter to delete this alarm.", alarm.prompt); diff --git a/codex-rs/tui/src/chatwidget/tests/alarms.rs b/codex-rs/tui/src/chatwidget/tests/alarms.rs index 49e84d76f0..288c256b54 100644 --- a/codex-rs/tui/src/chatwidget/tests/alarms.rs +++ b/codex-rs/tui/src/chatwidget/tests/alarms.rs @@ -1,5 +1,6 @@ use super::*; use codex_app_server_protocol::AlarmDelivery; +use codex_app_server_protocol::AlarmTrigger; use codex_app_server_protocol::ThreadAlarm; use codex_app_server_protocol::ThreadAlarmFiredNotification; use insta::assert_snapshot; @@ -13,9 +14,11 @@ async fn thread_alarm_fired_renders_prompt_history() { thread_id: ThreadId::new().to_string(), alarm: ThreadAlarm { id: "alarm-1".to_string(), - cron_expression: "@after-turn".to_string(), + trigger: AlarmTrigger::Delay { + seconds: 0, + repeat: None, + }, prompt: "Give me a random animal name.".to_string(), - run_once: false, delivery: AlarmDelivery::AfterTurn, created_at: 0, next_run_at: None, @@ -27,7 +30,7 @@ async fn thread_alarm_fired_renders_prompt_history() { let cells = drain_insert_history(&mut rx); let rendered = lines_to_single_string(&cells[0]); - assert_snapshot!(rendered, @"• Give me a random animal name. Running thread alarm • @after-turn • after-turn + assert_snapshot!(rendered, @"• Give me a random animal name. Running thread alarm • delay 0s • one-shot • after-turn "); } @@ -39,9 +42,11 @@ async fn thread_alarms_popup_keeps_selected_alarm_prompt_visible() { ThreadId::new(), vec![ThreadAlarm { id: "alarm-1".to_string(), - cron_expression: "@after-turn".to_string(), + trigger: AlarmTrigger::Delay { + seconds: 0, + repeat: None, + }, prompt: "Give me a random animal name.".to_string(), - run_once: false, delivery: AlarmDelivery::AfterTurn, created_at: 0, next_run_at: None, diff --git a/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__alarms__thread_alarms_popup_keeps_selected_alarm_prompt_visible.snap b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__alarms__thread_alarms_popup_keeps_selected_alarm_prompt_visible.snap index 36736cb9ae..4fdd33ab63 100644 --- a/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__alarms__thread_alarms_popup_keeps_selected_alarm_prompt_visible.snap +++ b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__alarms__thread_alarms_popup_keeps_selected_alarm_prompt_visible.snap @@ -5,7 +5,7 @@ expression: popup Thread alarms Review an alarm, then press Enter to delete it. -› 1. @after-turn Give me a random animal name. - Press Enter to delete this alarm. +› 1. delay 0s • one-shot Give me a random animal name. + Press Enter to delete this alarm. Press enter to confirm or esc to go back