mirror of
https://github.com/openai/codex.git
synced 2026-11-08 20:40:33 +00:00
Switch alarms to structured triggers
This commit is contained in:
Generated
+4
@@ -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\"]}}",
|
||||
|
||||
Generated
+44
-1
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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, };
|
||||
@@ -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, };
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<bool>,
|
||||
},
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(rename_all = "camelCase")]
|
||||
Schedule {
|
||||
dtstart: Option<String>,
|
||||
rrule: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[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<bool>,
|
||||
pub delivery: AlarmDelivery,
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<bool>,
|
||||
},
|
||||
Schedule {
|
||||
dtstart: Option<String>,
|
||||
rrule: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct TriggerTiming {
|
||||
pub(crate) trigger: AlarmTrigger,
|
||||
pub(crate) pending_run: bool,
|
||||
pub(crate) next_run_at: Option<i64>,
|
||||
pub(crate) timer_delay: Option<Duration>,
|
||||
}
|
||||
|
||||
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<Utc>,
|
||||
now: DateTime<Utc>,
|
||||
) -> Result<TriggerTiming, String> {
|
||||
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<i64>,
|
||||
now: DateTime<Utc>,
|
||||
) -> Result<TriggerTiming, String> {
|
||||
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<Utc>,
|
||||
) -> Result<Option<i64>, 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<Utc>,
|
||||
now: DateTime<Utc>,
|
||||
timezone: Tz,
|
||||
) -> Result<TriggerTiming, String> {
|
||||
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<i64>,
|
||||
now: DateTime<Utc>,
|
||||
timezone: Tz,
|
||||
) -> Result<TriggerTiming, String> {
|
||||
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<Utc>,
|
||||
timezone: Tz,
|
||||
) -> Result<Option<i64>, 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<Utc>,
|
||||
timezone: Tz,
|
||||
) -> Result<AlarmTrigger, String> {
|
||||
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<i64>,
|
||||
now: DateTime<Utc>,
|
||||
) -> 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<String>) -> Option<String> {
|
||||
value
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn checked_add_seconds(start: DateTime<Utc>, seconds: u64) -> Result<DateTime<Utc>, 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<i64> {
|
||||
let seconds = i64::try_from(seconds).ok()?;
|
||||
created_at.checked_add(seconds)
|
||||
}
|
||||
|
||||
fn next_delay_recurring_run_at(
|
||||
created_at: DateTime<Utc>,
|
||||
seconds: u64,
|
||||
now: DateTime<Utc>,
|
||||
) -> Result<Option<i64>, 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<Utc>,
|
||||
) -> Result<Option<i64>, 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<Utc>) -> Option<Duration> {
|
||||
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<DateTime<Utc>, 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<Option<i64>, 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<Utc>,
|
||||
timezone: Tz,
|
||||
) -> Result<Option<i64>, 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<RRuleSet, String> {
|
||||
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::<RRuleSet>()
|
||||
.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<DateTime<Utc>, 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, String> {
|
||||
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<Utc>, 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::<chrono_tz::Tz>().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> {
|
||||
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")
|
||||
);
|
||||
}
|
||||
}
|
||||
+182
-261
@@ -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<i64>,
|
||||
@@ -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<Utc>,
|
||||
pub(crate) now: chrono::DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[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<CancellationToken>,
|
||||
}
|
||||
|
||||
#[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<Self, String> {
|
||||
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::<u64>().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<Self, String> {
|
||||
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<Utc>) -> Option<DateTime<Utc>> {
|
||||
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<CancellationToken>,
|
||||
) -> Result<ThreadAlarm, String> {
|
||||
) -> Result<(ThreadAlarm, Option<AlarmTimerSpec>), 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<Utc>,
|
||||
now: chrono::DateTime<Utc>,
|
||||
timer_cancel: Option<CancellationToken>,
|
||||
) -> Result<Option<AlarmTimerSpec>, 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<AlarmRuntime> {
|
||||
@@ -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<Utc>) -> bool {
|
||||
pub(crate) fn mark_alarm_due(&mut self, id: &str, now: chrono::DateTime<Utc>) -> 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<Utc>,
|
||||
) -> Option<AlarmTimerSpec> {
|
||||
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<Utc>,
|
||||
now: chrono::DateTime<Utc>,
|
||||
can_after_turn: bool,
|
||||
can_steer_current_turn: bool,
|
||||
) -> Option<ClaimedAlarm> {
|
||||
@@ -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<Vec<Persis
|
||||
));
|
||||
}
|
||||
};
|
||||
serde_json::from_slice(&bytes).map_err(|err| {
|
||||
let raw_alarms: Vec<serde_json::Value> = 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::<PersistedAlarm>(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<u64> {
|
||||
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::<u64>().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<bool>) -> 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::<Utc>::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,
|
||||
}]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+24
-45
@@ -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<Self>,
|
||||
cron_expression: String,
|
||||
trigger: ThreadAlarmTrigger,
|
||||
prompt: String,
|
||||
run_once: bool,
|
||||
delivery: AlarmDelivery,
|
||||
) -> Result<ThreadAlarm, String> {
|
||||
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<Self>) {
|
||||
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::<PersistedAlarm>::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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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<ThreadAlarm, String> {
|
||||
self.codex
|
||||
.session
|
||||
.create_alarm(cron_expression, prompt, run_once, delivery)
|
||||
.create_alarm(trigger, prompt, delivery)
|
||||
.await
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<bool>,
|
||||
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| {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}}"}.
|
||||
|
||||
@@ -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(_))
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(", ");
|
||||
|
||||
@@ -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(),
|
||||
]),
|
||||
|
||||
+300
-163
@@ -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<bool>,
|
||||
pub(crate) delivery: AlarmDelivery,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ParsedAlarmSpecResponse {
|
||||
cron_expression: String,
|
||||
prompt: String,
|
||||
run_once: Option<bool>,
|
||||
delivery: AlarmDelivery,
|
||||
}
|
||||
|
||||
impl ParsedAlarmSpecResponse {
|
||||
fn into_parsed(self) -> std::result::Result<ParsedAlarmSpec, String> {
|
||||
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<Regex> = 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<Regex> = 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<Regex> = 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<ParsedAlarmSpec, String> {
|
||||
parse_alarm_spec_text(&spec)
|
||||
}
|
||||
|
||||
fn parse_alarm_spec_text(spec: &str) -> std::result::Result<ParsedAlarmSpec, String> {
|
||||
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<char, String> {
|
||||
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<String, String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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::<String>();
|
||||
(!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<ParsedAlarmSpec, String> {
|
||||
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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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<AppServerStartedThread> {
|
||||
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<bool>,
|
||||
delivery: AlarmDelivery,
|
||||
) -> Result<ThreadAlarm> {
|
||||
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,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user