Skip to main content

agentos_client/
config.rs

1//! Configuration types: `AgentOsConfig` (= TS `AgentOsOptions`), the permissions tree, root
2//! filesystem config, mount config, and the schedule-driver abstraction.
3//!
4//! Ported from `packages/core/src/agent-os.ts` (`AgentOsOptions`), `runtime.ts` (`Permissions`),
5//! `layers.ts` / `overlay-filesystem.ts` (root/overlay), and `cron/` (schedule driver).
6//!
7//! Non-serializable parameters (`MountConfig::Plain.driver`, `CronAction::Callback`) are in-process
8//! only and become `Arc<dyn ...>` trait objects; they cannot cross the wire and are gated exactly as
9//! the actor layer gates them.
10
11use std::collections::BTreeMap;
12use std::sync::Arc;
13
14use serde::{Deserialize, Serialize};
15
16use crate::fs::VirtualFileSystem;
17pub use agentos_vm_config::{
18    VmGroupConfig, VmSqliteCallbackRequest, VmSqliteCallbackResponse, VmSqliteDescriptor,
19    VmSqliteQueryResult, VmSqliteStatement, VmSqliteValue, VmUserAccountConfig, VmUserConfig,
20    VM_SQLITE_CALLBACK_NAMESPACE,
21};
22
23pub type SidecarSqliteCallback = Arc<
24    dyn Fn(
25            VmSqliteCallbackRequest,
26        ) -> futures::future::BoxFuture<'static, Result<VmSqliteCallbackResponse, String>>
27        + Send
28        + Sync,
29>;
30
31/// Resolved client options (= TS `AgentOsOptions`). All fields optional with documented defaults.
32///
33/// Keep this Rust mirror in sync with `packages/core/src/agent-os.ts::AgentOsOptions`
34/// and `packages/core/src/options-schema.ts::agentOsOptionsSchema`.
35#[derive(Default)]
36pub struct AgentOsConfig {
37    /// VM-scoped SQLite backend shared by VFS metadata/storage and agentOS
38    /// durable state.
39    pub database: Option<agentos_vm_config::VmSqliteDescriptor>,
40    /// Trusted callback for [`VmSqliteDescriptor::HostCallback`]. Hosted actors
41    /// use this to execute VM database operations through Rivet SQLite.
42    pub sidecar_sqlite_callback: Option<SidecarSqliteCallback>,
43    /// Initial virtual Linux credentials and account record. Defaults to `1000:1000` (`agentos`).
44    pub user: Option<VmUserConfig>,
45    /// Complete initial VM environment. `None` selects the sidecar's bundled base environment;
46    /// `Some(empty)` deliberately starts with an empty environment.
47    pub environment: Option<BTreeMap<String, String>>,
48    /// Software packages to install (flattened). Default `[]`.
49    pub software: Vec<SoftwareInput>,
50    /// Package directories to project into the VM's `/opt/agentos` tree (the
51    /// agentos package projection). Each entry is a host dir containing an
52    /// `agentos-package.json` manifest + the package payload. Default `[]`.
53    pub packages: Vec<PackageRef>,
54    /// Guest mount point for the package projection. Default `/opt/agentos`
55    /// (agentos's `OPT_AGENTOS_ROOT`) when `None`.
56    pub packages_mount_at: Option<String>,
57    /// Trusted Core package acquisition limits and local-test transport policy.
58    /// Hosted actor callers cannot configure this process-owned policy.
59    pub package_resolver: Option<crate::software::PackageResolverOptions>,
60    /// Loopback ports exempt from the default outbound-to-host block.
61    pub loopback_exempt_ports: Vec<u16>,
62    /// Allowed Node.js builtins. Default: the hardened native-bridge set.
63    pub allowed_node_builtins: Option<Vec<String>>,
64    /// Opt in to a high-resolution monotonic clock for guest JavaScript.
65    /// `None` selects the hardened millisecond-resolution default.
66    pub high_resolution_time: Option<bool>,
67    /// Root filesystem configuration. Default: overlay + bundled base snapshot.
68    pub root_filesystem: RootFilesystemConfig,
69    /// Additional mounts.
70    pub mounts: Vec<MountConfig>,
71    /// Schedule driver used by the cron manager. Default: [`TimerScheduleDriver`].
72    pub schedule_driver: Option<Arc<dyn ScheduleDriver>>,
73    /// Host function collections to register, keyed by collection name.
74    pub host_functions: HostFunctionCollections,
75    /// Rust-only sidecar callback handler for `js_bridge`-style plugin requests.
76    pub sidecar_js_bridge_callback: Option<SidecarJsBridgeCallback>,
77    /// Permission policy. Default: allow-all.
78    pub permissions: Option<Permissions>,
79    /// Operator-tunable VM limits. Default: sidecar/kernel built-ins.
80    pub limits: Option<AgentOsLimits>,
81    /// Sidecar placement/config. Default: shared `default` pool.
82    pub sidecar: Option<AgentOsSidecarConfig>,
83    /// Absolute path to the `agentos-native-sidecar` binary, resolved from the npm
84    /// package on the TypeScript side. Threaded to `SidecarProcess::spawn`
85    /// instead of relying on the
86    /// `AGENTOS_SIDECAR_BIN` env var. `None` falls back to env, then `PATH`.
87    pub sidecar_binary_path: Option<String>,
88}
89
90/// Builder for [`AgentOsConfig`].
91#[derive(Default)]
92pub struct AgentOsConfigBuilder {
93    config: AgentOsConfig,
94}
95
96impl AgentOsConfigBuilder {
97    pub fn new() -> Self {
98        Self::default()
99    }
100
101    pub fn database(mut self, database: agentos_vm_config::VmSqliteDescriptor) -> Self {
102        self.config.database = Some(database);
103        self
104    }
105
106    pub fn sidecar_sqlite_callback(mut self, callback: SidecarSqliteCallback) -> Self {
107        self.config.sidecar_sqlite_callback = Some(callback);
108        self
109    }
110
111    pub fn packages(mut self, packages: Vec<PackageRef>) -> Self {
112        self.config.packages = packages;
113        self
114    }
115
116    pub fn packages_mount_at(mut self, mount_at: impl Into<String>) -> Self {
117        self.config.packages_mount_at = Some(mount_at.into());
118        self
119    }
120
121    pub fn package_resolver(mut self, options: crate::software::PackageResolverOptions) -> Self {
122        self.config.package_resolver = Some(options);
123        self
124    }
125
126    pub fn loopback_exempt_ports(mut self, ports: Vec<u16>) -> Self {
127        self.config.loopback_exempt_ports = ports;
128        self
129    }
130
131    pub fn allowed_node_builtins(mut self, builtins: Vec<String>) -> Self {
132        self.config.allowed_node_builtins = Some(builtins);
133        self
134    }
135
136    pub fn user(mut self, user: VmUserConfig) -> Self {
137        self.config.user = Some(user);
138        self
139    }
140
141    pub fn environment(mut self, environment: BTreeMap<String, String>) -> Self {
142        self.config.environment = Some(environment);
143        self
144    }
145
146    pub fn high_resolution_time(mut self, enabled: bool) -> Self {
147        self.config.high_resolution_time = Some(enabled);
148        self
149    }
150
151    pub fn root_filesystem(mut self, root: RootFilesystemConfig) -> Self {
152        self.config.root_filesystem = root;
153        self
154    }
155
156    pub fn mounts(mut self, mounts: Vec<MountConfig>) -> Self {
157        self.config.mounts = mounts;
158        self
159    }
160
161    pub fn schedule_driver(mut self, driver: Arc<dyn ScheduleDriver>) -> Self {
162        self.config.schedule_driver = Some(driver);
163        self
164    }
165
166    pub fn host_functions(mut self, host_functions: HostFunctionCollections) -> Self {
167        self.config.host_functions = host_functions;
168        self
169    }
170
171    pub fn sidecar_js_bridge_callback(mut self, callback: SidecarJsBridgeCallback) -> Self {
172        self.config.sidecar_js_bridge_callback = Some(callback);
173        self
174    }
175
176    pub fn permissions(mut self, permissions: Permissions) -> Self {
177        self.config.permissions = Some(permissions);
178        self
179    }
180
181    pub fn limits(mut self, limits: AgentOsLimits) -> Self {
182        self.config.limits = Some(limits);
183        self
184    }
185
186    pub fn sidecar(mut self, sidecar: AgentOsSidecarConfig) -> Self {
187        self.config.sidecar = Some(sidecar);
188        self
189    }
190
191    pub fn sidecar_binary_path(mut self, path: impl Into<String>) -> Self {
192        self.config.sidecar_binary_path = Some(path.into());
193        self
194    }
195
196    pub fn build(self) -> AgentOsConfig {
197        self.config
198    }
199}
200
201impl AgentOsConfig {
202    /// Validate the complete VM configuration without starting a sidecar.
203    ///
204    /// Hosted adapters use this to reject invalid creation input before Rivet
205    /// persists actor state. The same serializer is used by [`AgentOs::create`](crate::AgentOs::create),
206    /// so validation cannot drift into an actor-owned copy.
207    pub fn validate(&self) -> Result<(), crate::ClientError> {
208        crate::agent_os::validate_config(self)
209    }
210}
211
212/// The kind of a software package, which decides how it is mounted into the VM. Mirrors the TS
213/// descriptor `type` discriminator (`packages/core/src/packages.ts`).
214#[cfg_attr(feature = "contract", derive(ts_rs::TS))]
215#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
216#[serde(rename_all = "kebab-case")]
217pub enum SoftwareKind {
218    /// A directory of wasm command binaries. Mounted at `/__agentos/commands/{index}/` so the
219    /// sidecar's command discovery can resolve guest commands (`echo`, `sh`, `grep`, ...).
220    #[default]
221    WasmCommands,
222    /// A host-function package. Not mounted as a command directory.
223    HostFunction,
224}
225
226/// A flattened software package input.
227#[cfg_attr(feature = "contract", derive(ts_rs::TS))]
228#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
229pub struct SoftwareInput {
230    pub package: String,
231    #[serde(default, skip_serializing_if = "Option::is_none")]
232    pub version: Option<String>,
233    /// How the package is mounted into the VM. Defaults to [`SoftwareKind::WasmCommands`].
234    #[serde(default)]
235    pub kind: SoftwareKind,
236}
237
238/// A reference to a packed `.aospkg` package for the `/opt/agentos`
239/// projection. A directory path remains accepted for local transition
240/// fixtures.
241#[cfg_attr(feature = "contract", derive(ts_rs::TS))]
242#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
243pub struct PackageRef {
244    /// Serialized as `packagePath` — the single package-ref spelling on every
245    /// JSON boundary (registry exports, actor config, client config).
246    #[serde(rename = "packagePath")]
247    pub path: String,
248}
249
250/// A host-side host function callback. Receives the validated JSON input, returns a JSON result or an
251/// error string. Stays host-side (never crosses to the guest); the guest invokes it by name via the
252/// sidecar host-callback channel.
253pub type HostFunctionCallback = Arc<
254    dyn Fn(
255            serde_json::Value,
256        ) -> futures::future::BoxFuture<'static, Result<serde_json::Value, String>>
257        + Send
258        + Sync,
259>;
260
261/// A sidecar-initiated `js_bridge`-style filesystem callback.
262#[derive(Debug, Clone, PartialEq, Eq)]
263pub struct SidecarJsBridgeCall {
264    pub call_id: String,
265    pub mount_id: String,
266    pub operation: String,
267    pub args: serde_json::Value,
268}
269
270/// Host-side handler for sidecar `JsBridgeCallRequest` payloads.
271///
272/// This is Rust-only and intentionally not JSON-serializable. Managed services
273/// use it to bind native sidecar storage to their durable database without
274/// teaching agentOS about the hosting framework.
275pub type SidecarJsBridgeCallback = Arc<
276    dyn Fn(
277            SidecarJsBridgeCall,
278        )
279            -> futures::future::BoxFuture<'static, Result<Option<serde_json::Value>, String>>
280        + Send
281        + Sync,
282>;
283
284/// A single host function. The key it is registered under names it, and the
285/// input schema's `description` documents it for the agent.
286#[derive(Clone)]
287pub struct HostFunction {
288    /// JSON Schema for the host function input (forwarded to the sidecar `register_host_callbacks` definition).
289    pub input_schema: serde_json::Value,
290    pub timeout_ms: Option<u64>,
291    /// Host-side implementation, invoked when the guest calls `<collection>:<function>`.
292    pub execute: HostFunctionCallback,
293}
294
295/// One collection of host functions, keyed by function name. Each key becomes a
296/// subcommand of the collection's CLI binary and a method on its guest global.
297pub type HostFunctionCollection = BTreeMap<String, HostFunction>;
298
299/// Host function collections, keyed by collection name (in-process;
300/// implementations stay host-side). Each key becomes the CLI binary
301/// `agentos-{name}` and a frozen guest global; functions are exposed to the
302/// guest as `<collection>:<function>` and dispatched back to
303/// [`HostFunction::execute`] via the sidecar host-callback channel.
304pub type HostFunctionCollections = BTreeMap<String, HostFunctionCollection>;
305
306/// A host function resolved to the names the VM uses.
307#[derive(Clone)]
308pub struct ResolvedHostFunction {
309    /// Kebab-case command name. Becomes the CLI subcommand.
310    pub name: String,
311    /// Taken from the input schema's `description`.
312    pub description: String,
313    pub input_schema: serde_json::Value,
314    pub timeout_ms: Option<u64>,
315    pub execute: HostFunctionCallback,
316}
317
318/// A collection resolved to the names the VM uses. Keys arrive as identifiers
319/// and are converted once here, so the rest of the client and the sidecar only
320/// ever see kebab-case command names.
321#[derive(Clone)]
322pub struct ResolvedHostFunctions {
323    /// Kebab-case collection name. Becomes the CLI suffix: `agentos-{name}`.
324    pub name: String,
325    pub functions: Vec<ResolvedHostFunction>,
326}
327
328/// Convert a registration key to its command name. `listOrders` and
329/// `list-orders` both become `list-orders`, so the guest sees one spelling
330/// whichever the caller wrote. Mirrors `hostFunctionCommandName` in the
331/// TypeScript client.
332pub fn host_function_command_name(key: &str) -> String {
333    let mut out = String::with_capacity(key.len() + 4);
334    let chars: Vec<char> = key.chars().collect();
335    for (index, character) in chars.iter().enumerate() {
336        if character.is_ascii_uppercase() && index > 0 {
337            let previous = chars[index - 1];
338            let next_is_lower = chars
339                .get(index + 1)
340                .is_some_and(|next| next.is_ascii_lowercase());
341            if previous.is_ascii_lowercase()
342                || previous.is_ascii_digit()
343                || (previous.is_ascii_uppercase() && next_is_lower)
344            {
345                out.push('-');
346            }
347        }
348        out.extend(character.to_lowercase());
349    }
350    out
351}
352
353fn is_command_name(name: &str) -> bool {
354    !name.is_empty()
355        && !name.starts_with('-')
356        && !name.ends_with('-')
357        && !name.contains("--")
358        && name.chars().all(|character| {
359            character.is_ascii_lowercase() || character.is_ascii_digit() || character == '-'
360        })
361}
362
363fn to_command_name(kind: &str, key: &str) -> Result<String, String> {
364    let name = host_function_command_name(key);
365    if is_command_name(&name) {
366        Ok(name)
367    } else {
368        Err(format!(
369            "{kind} name \"{key}\" must be alphanumeric, written in camelCase or with single hyphen separators"
370        ))
371    }
372}
373
374/// The description the agent sees, taken from the input schema's `description`.
375fn schema_description(input_schema: &serde_json::Value) -> String {
376    input_schema
377        .get("description")
378        .and_then(serde_json::Value::as_str)
379        .unwrap_or_default()
380        .to_string()
381}
382
383/// Resolve the caller's collections into the shape the client and sidecar use.
384/// Fails on a key that cannot become a command name, and on two keys that
385/// collide once converted.
386pub fn resolve_host_functions(
387    collections: &HostFunctionCollections,
388) -> Result<Vec<ResolvedHostFunctions>, String> {
389    let mut resolved = Vec::with_capacity(collections.len());
390    let mut seen_collections: BTreeMap<String, String> = BTreeMap::new();
391
392    for (collection_key, collection) in collections {
393        let name = to_command_name("Host function collection", collection_key)?;
394        if let Some(collided) = seen_collections.get(&name) {
395            return Err(format!(
396                "Host function collections \"{collided}\" and \"{collection_key}\" both resolve to the command name \"{name}\""
397            ));
398        }
399        seen_collections.insert(name.clone(), collection_key.clone());
400
401        let mut functions = Vec::with_capacity(collection.len());
402        let mut seen_functions: BTreeMap<String, String> = BTreeMap::new();
403        for (function_key, definition) in collection {
404            let function_name = to_command_name("Host function", function_key)?;
405            if let Some(collided) = seen_functions.get(&function_name) {
406                return Err(format!(
407                    "Host functions \"{collided}\" and \"{function_key}\" in collection \"{collection_key}\" both resolve to the command name \"{function_name}\""
408                ));
409            }
410            seen_functions.insert(function_name.clone(), function_key.clone());
411            functions.push(ResolvedHostFunction {
412                name: function_name,
413                description: schema_description(&definition.input_schema),
414                input_schema: definition.input_schema.clone(),
415                timeout_ms: definition.timeout_ms,
416                execute: definition.execute.clone(),
417            });
418        }
419
420        resolved.push(ResolvedHostFunctions { name, functions });
421    }
422
423    Ok(resolved)
424}
425
426// ---------------------------------------------------------------------------
427// VM limits (agent-os.ts AgentOsLimits / sidecar/limits.ts)
428// ---------------------------------------------------------------------------
429
430/// Operator-tunable runtime limits for a VM. Every field is optional; unset fields fall back to the
431/// sidecar defaults.
432#[cfg_attr(feature = "contract", derive(ts_rs::TS))]
433#[cfg_attr(feature = "contract", ts(rename_all = "camelCase"))]
434#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
435#[serde(deny_unknown_fields)]
436pub struct AgentOsLimits {
437    #[serde(
438        default,
439        rename = "agentosPackages",
440        skip_serializing_if = "Option::is_none"
441    )]
442    #[cfg_attr(feature = "contract", ts(optional))]
443    pub agentos_packages: Option<AgentOsPackageLimits>,
444    #[serde(default, skip_serializing_if = "Option::is_none")]
445    pub resources: Option<ResourceLimits>,
446    #[serde(default, skip_serializing_if = "Option::is_none")]
447    pub http: Option<HttpLimits>,
448    #[serde(default, skip_serializing_if = "Option::is_none")]
449    #[cfg_attr(feature = "contract", ts(optional))]
450    pub tls: Option<TlsLimits>,
451    #[serde(
452        default,
453        rename = "hostFunctions",
454        skip_serializing_if = "Option::is_none"
455    )]
456    pub host_functions: Option<HostFunctionLimits>,
457    #[serde(default, skip_serializing_if = "Option::is_none")]
458    pub plugins: Option<PluginLimits>,
459    #[serde(default, skip_serializing_if = "Option::is_none")]
460    pub sqlite: Option<SqliteLimits>,
461    #[serde(default, rename = "jsRuntime", skip_serializing_if = "Option::is_none")]
462    pub js_runtime: Option<JsRuntimeLimits>,
463    #[serde(default, skip_serializing_if = "Option::is_none")]
464    pub python: Option<PythonLimits>,
465    #[serde(default, skip_serializing_if = "Option::is_none")]
466    pub wasm: Option<WasmLimits>,
467    #[serde(default, skip_serializing_if = "Option::is_none")]
468    #[cfg_attr(feature = "contract", ts(optional))]
469    pub execution: Option<ExecutionLimits>,
470    #[serde(default, skip_serializing_if = "Option::is_none")]
471    pub process: Option<ProcessLimits>,
472}
473
474#[cfg_attr(feature = "contract", derive(ts_rs::TS))]
475#[cfg_attr(feature = "contract", ts(rename_all = "camelCase"))]
476#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
477#[serde(rename_all = "camelCase", deny_unknown_fields)]
478pub struct AgentOsPackageLimits {
479    #[serde(default, skip_serializing_if = "Option::is_none")]
480    #[cfg_attr(feature = "contract", ts(optional))]
481    pub max_mounts: Option<u64>,
482}
483
484#[cfg_attr(feature = "contract", derive(ts_rs::TS))]
485#[cfg_attr(feature = "contract", ts(rename_all = "camelCase"))]
486#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
487#[serde(deny_unknown_fields)]
488pub struct ResourceLimits {
489    #[serde(default, rename = "cpuCount", skip_serializing_if = "Option::is_none")]
490    pub cpu_count: Option<u64>,
491    #[serde(
492        default,
493        rename = "maxProcesses",
494        skip_serializing_if = "Option::is_none"
495    )]
496    pub max_processes: Option<u64>,
497    #[serde(
498        default,
499        rename = "maxOpenFds",
500        skip_serializing_if = "Option::is_none"
501    )]
502    pub max_open_fds: Option<u64>,
503    #[serde(default, rename = "maxPipes", skip_serializing_if = "Option::is_none")]
504    pub max_pipes: Option<u64>,
505    #[serde(default, rename = "maxPtys", skip_serializing_if = "Option::is_none")]
506    pub max_ptys: Option<u64>,
507    #[serde(
508        default,
509        rename = "maxSockets",
510        skip_serializing_if = "Option::is_none"
511    )]
512    pub max_sockets: Option<u64>,
513    #[serde(
514        default,
515        rename = "maxConnections",
516        skip_serializing_if = "Option::is_none"
517    )]
518    pub max_connections: Option<u64>,
519    #[serde(
520        default,
521        rename = "maxSocketBufferedBytes",
522        skip_serializing_if = "Option::is_none"
523    )]
524    pub max_socket_buffered_bytes: Option<u64>,
525    #[serde(
526        default,
527        rename = "maxSocketDatagramQueueLen",
528        skip_serializing_if = "Option::is_none"
529    )]
530    pub max_socket_datagram_queue_len: Option<u64>,
531    #[serde(
532        default,
533        rename = "maxFilesystemBytes",
534        skip_serializing_if = "Option::is_none"
535    )]
536    pub max_filesystem_bytes: Option<u64>,
537    #[serde(
538        default,
539        rename = "maxInodeCount",
540        skip_serializing_if = "Option::is_none"
541    )]
542    pub max_inode_count: Option<u64>,
543    #[serde(
544        default,
545        rename = "maxBlockingReadMs",
546        skip_serializing_if = "Option::is_none"
547    )]
548    pub max_blocking_read_ms: Option<u64>,
549    #[serde(
550        default,
551        rename = "maxPreadBytes",
552        skip_serializing_if = "Option::is_none"
553    )]
554    pub max_pread_bytes: Option<u64>,
555    #[serde(
556        default,
557        rename = "maxFdWriteBytes",
558        skip_serializing_if = "Option::is_none"
559    )]
560    pub max_fd_write_bytes: Option<u64>,
561    #[serde(
562        default,
563        rename = "maxProcessArgvBytes",
564        skip_serializing_if = "Option::is_none"
565    )]
566    pub max_process_argv_bytes: Option<u64>,
567    #[serde(
568        default,
569        rename = "maxProcessEnvBytes",
570        skip_serializing_if = "Option::is_none"
571    )]
572    pub max_process_env_bytes: Option<u64>,
573    #[serde(
574        default,
575        rename = "maxReaddirEntries",
576        skip_serializing_if = "Option::is_none"
577    )]
578    pub max_readdir_entries: Option<u64>,
579    #[serde(
580        default,
581        rename = "maxWasmFuel",
582        skip_serializing_if = "Option::is_none"
583    )]
584    pub max_wasm_fuel: Option<u64>,
585    #[serde(
586        default,
587        rename = "maxWasmMemoryBytes",
588        skip_serializing_if = "Option::is_none"
589    )]
590    pub max_wasm_memory_bytes: Option<u64>,
591    #[serde(
592        default,
593        rename = "maxWasmStackBytes",
594        skip_serializing_if = "Option::is_none"
595    )]
596    pub max_wasm_stack_bytes: Option<u64>,
597}
598
599#[cfg_attr(feature = "contract", derive(ts_rs::TS))]
600#[cfg_attr(feature = "contract", ts(rename_all = "camelCase"))]
601#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
602#[serde(deny_unknown_fields)]
603pub struct HttpLimits {
604    #[serde(
605        default,
606        rename = "maxFetchResponseBytes",
607        skip_serializing_if = "Option::is_none"
608    )]
609    pub max_fetch_response_bytes: Option<u64>,
610}
611
612/// Per-VM TLS plaintext buffering overrides. Omitted fields use sidecar defaults.
613#[cfg_attr(feature = "contract", derive(ts_rs::TS))]
614#[cfg_attr(feature = "contract", ts(rename_all = "camelCase"))]
615#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
616#[serde(rename_all = "camelCase", deny_unknown_fields)]
617pub struct TlsLimits {
618    #[serde(default, skip_serializing_if = "Option::is_none")]
619    #[cfg_attr(feature = "contract", ts(optional))]
620    pub max_buffered_bytes: Option<u64>,
621}
622
623/// Per-VM language-execution retention and warning overrides.
624#[cfg_attr(feature = "contract", derive(ts_rs::TS))]
625#[cfg_attr(feature = "contract", ts(rename_all = "camelCase"))]
626#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
627#[serde(rename_all = "camelCase", deny_unknown_fields)]
628pub struct ExecutionLimits {
629    #[serde(default, skip_serializing_if = "Option::is_none")]
630    #[cfg_attr(feature = "contract", ts(optional))]
631    pub completed_ttl_ms: Option<u64>,
632    #[serde(default, skip_serializing_if = "Option::is_none")]
633    #[cfg_attr(feature = "contract", ts(optional))]
634    pub max_completed_executions: Option<u64>,
635    #[serde(default, skip_serializing_if = "Option::is_none")]
636    #[cfg_attr(feature = "contract", ts(optional))]
637    pub live_execution_warning_threshold: Option<u64>,
638}
639
640#[cfg_attr(feature = "contract", derive(ts_rs::TS))]
641#[cfg_attr(feature = "contract", ts(rename_all = "camelCase"))]
642#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
643#[serde(deny_unknown_fields)]
644pub struct HostFunctionLimits {
645    #[serde(
646        default,
647        rename = "defaultTimeoutMs",
648        skip_serializing_if = "Option::is_none"
649    )]
650    pub default_timeout_ms: Option<u64>,
651    #[serde(
652        default,
653        rename = "maxTimeoutMs",
654        skip_serializing_if = "Option::is_none"
655    )]
656    pub max_timeout_ms: Option<u64>,
657    #[serde(
658        default,
659        rename = "maxRegisteredCollections",
660        skip_serializing_if = "Option::is_none"
661    )]
662    pub max_registered_collections: Option<u64>,
663    #[serde(
664        default,
665        rename = "maxRegisteredFunctionsPerVm",
666        skip_serializing_if = "Option::is_none"
667    )]
668    pub max_registered_functions_per_vm: Option<u64>,
669    #[serde(
670        default,
671        rename = "maxFunctionsPerCollection",
672        skip_serializing_if = "Option::is_none"
673    )]
674    pub max_functions_per_collection: Option<u64>,
675    #[serde(
676        default,
677        rename = "maxSchemaBytes",
678        skip_serializing_if = "Option::is_none"
679    )]
680    pub max_schema_bytes: Option<u64>,
681    #[serde(
682        default,
683        rename = "maxExamplesPerFunction",
684        skip_serializing_if = "Option::is_none"
685    )]
686    pub max_examples_per_function: Option<u64>,
687    #[serde(
688        default,
689        rename = "maxExampleInputBytes",
690        skip_serializing_if = "Option::is_none"
691    )]
692    pub max_example_input_bytes: Option<u64>,
693}
694
695#[cfg_attr(feature = "contract", derive(ts_rs::TS))]
696#[cfg_attr(feature = "contract", ts(rename_all = "camelCase"))]
697#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
698#[serde(deny_unknown_fields)]
699pub struct PluginLimits {
700    #[serde(
701        default,
702        rename = "maxPersistedManifestBytes",
703        skip_serializing_if = "Option::is_none"
704    )]
705    pub max_persisted_manifest_bytes: Option<u64>,
706    #[serde(
707        default,
708        rename = "maxPersistedManifestFileBytes",
709        skip_serializing_if = "Option::is_none"
710    )]
711    pub max_persisted_manifest_file_bytes: Option<u64>,
712}
713
714#[cfg_attr(feature = "contract", derive(ts_rs::TS))]
715#[cfg_attr(feature = "contract", ts(rename_all = "camelCase"))]
716#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
717#[serde(deny_unknown_fields)]
718pub struct SqliteLimits {
719    #[serde(
720        default,
721        rename = "maxResultBytes",
722        skip_serializing_if = "Option::is_none"
723    )]
724    pub max_result_bytes: Option<u64>,
725}
726
727#[cfg_attr(feature = "contract", derive(ts_rs::TS))]
728#[cfg_attr(feature = "contract", ts(rename_all = "camelCase"))]
729#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
730#[serde(deny_unknown_fields)]
731pub struct JsRuntimeLimits {
732    #[serde(
733        default,
734        rename = "v8HeapLimitMb",
735        skip_serializing_if = "Option::is_none"
736    )]
737    pub v8_heap_limit_mb: Option<u64>,
738    #[serde(
739        default,
740        rename = "syncRpcWaitTimeoutMs",
741        skip_serializing_if = "Option::is_none"
742    )]
743    pub sync_rpc_wait_timeout_ms: Option<u64>,
744    #[serde(
745        default,
746        rename = "cpuTimeLimitMs",
747        skip_serializing_if = "Option::is_none"
748    )]
749    pub cpu_time_limit_ms: Option<u64>,
750    #[serde(
751        default,
752        rename = "wallClockLimitMs",
753        skip_serializing_if = "Option::is_none"
754    )]
755    pub wall_clock_limit_ms: Option<u64>,
756    #[serde(
757        default,
758        rename = "importCacheMaterializeTimeoutMs",
759        skip_serializing_if = "Option::is_none"
760    )]
761    pub import_cache_materialize_timeout_ms: Option<u64>,
762    #[serde(
763        default,
764        rename = "capturedOutputLimitBytes",
765        skip_serializing_if = "Option::is_none"
766    )]
767    pub captured_output_limit_bytes: Option<u64>,
768    #[serde(
769        default,
770        rename = "stdinBufferLimitBytes",
771        skip_serializing_if = "Option::is_none"
772    )]
773    pub stdin_buffer_limit_bytes: Option<u64>,
774    #[serde(
775        default,
776        rename = "eventPayloadLimitBytes",
777        skip_serializing_if = "Option::is_none"
778    )]
779    pub event_payload_limit_bytes: Option<u64>,
780    #[serde(
781        default,
782        rename = "v8IpcMaxFrameBytes",
783        skip_serializing_if = "Option::is_none"
784    )]
785    pub v8_ipc_max_frame_bytes: Option<u64>,
786}
787
788#[cfg_attr(feature = "contract", derive(ts_rs::TS))]
789#[cfg_attr(feature = "contract", ts(rename_all = "camelCase"))]
790#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
791#[serde(deny_unknown_fields)]
792pub struct PythonLimits {
793    #[serde(
794        default,
795        rename = "outputBufferMaxBytes",
796        skip_serializing_if = "Option::is_none"
797    )]
798    pub output_buffer_max_bytes: Option<u64>,
799    #[serde(
800        default,
801        rename = "executionTimeoutMs",
802        skip_serializing_if = "Option::is_none"
803    )]
804    pub execution_timeout_ms: Option<u64>,
805    #[serde(
806        default,
807        rename = "maxOldSpaceMb",
808        skip_serializing_if = "Option::is_none"
809    )]
810    pub max_old_space_mb: Option<u64>,
811    #[serde(
812        default,
813        rename = "vfsRpcTimeoutMs",
814        skip_serializing_if = "Option::is_none"
815    )]
816    pub vfs_rpc_timeout_ms: Option<u64>,
817}
818
819#[cfg_attr(feature = "contract", derive(ts_rs::TS))]
820#[cfg_attr(feature = "contract", ts(rename_all = "camelCase"))]
821#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
822#[serde(deny_unknown_fields)]
823pub struct WasmLimits {
824    #[serde(
825        default,
826        rename = "maxModuleFileBytes",
827        skip_serializing_if = "Option::is_none"
828    )]
829    pub max_module_file_bytes: Option<u64>,
830    #[serde(
831        default,
832        rename = "capturedOutputLimitBytes",
833        skip_serializing_if = "Option::is_none"
834    )]
835    pub captured_output_limit_bytes: Option<u64>,
836    #[serde(
837        default,
838        rename = "syncReadLimitBytes",
839        skip_serializing_if = "Option::is_none"
840    )]
841    pub sync_read_limit_bytes: Option<u64>,
842    #[serde(
843        default,
844        rename = "prewarmTimeoutMs",
845        skip_serializing_if = "Option::is_none"
846    )]
847    pub prewarm_timeout_ms: Option<u64>,
848    #[serde(
849        default,
850        rename = "runnerHeapLimitMb",
851        skip_serializing_if = "Option::is_none"
852    )]
853    pub runner_heap_limit_mb: Option<u64>,
854    #[serde(
855        default,
856        rename = "runnerCpuTimeLimitMs",
857        skip_serializing_if = "Option::is_none"
858    )]
859    pub runner_cpu_time_limit_ms: Option<u64>,
860}
861
862#[cfg_attr(feature = "contract", derive(ts_rs::TS))]
863#[cfg_attr(feature = "contract", ts(rename_all = "camelCase"))]
864#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
865#[serde(deny_unknown_fields)]
866pub struct ProcessLimits {
867    #[serde(
868        default,
869        rename = "maxSpawnFileActions",
870        skip_serializing_if = "Option::is_none"
871    )]
872    pub max_spawn_file_actions: Option<u64>,
873    #[serde(
874        default,
875        rename = "maxSpawnFileActionBytes",
876        skip_serializing_if = "Option::is_none"
877    )]
878    pub max_spawn_file_action_bytes: Option<u64>,
879    #[serde(
880        default,
881        rename = "pendingStdinBytes",
882        skip_serializing_if = "Option::is_none"
883    )]
884    pub pending_stdin_bytes: Option<u64>,
885    #[serde(
886        default,
887        rename = "pendingEventCount",
888        skip_serializing_if = "Option::is_none"
889    )]
890    pub pending_event_count: Option<u64>,
891    #[serde(
892        default,
893        rename = "pendingEventBytes",
894        skip_serializing_if = "Option::is_none"
895    )]
896    pub pending_event_bytes: Option<u64>,
897}
898
899// ---------------------------------------------------------------------------
900// Permissions tree (runtime.ts)
901// ---------------------------------------------------------------------------
902
903/// Top-level permission policy. All domains optional (`allowAll` when omitted).
904#[cfg_attr(feature = "contract", derive(ts_rs::TS))]
905#[cfg_attr(feature = "contract", ts(rename_all = "camelCase"))]
906#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
907#[serde(deny_unknown_fields)]
908pub struct Permissions {
909    #[serde(default, skip_serializing_if = "Option::is_none")]
910    pub fs: Option<FsPermissions>,
911    #[serde(default, skip_serializing_if = "Option::is_none")]
912    pub network: Option<PatternPermissions>,
913    #[serde(
914        default,
915        rename = "childProcess",
916        skip_serializing_if = "Option::is_none"
917    )]
918    pub child_process: Option<PatternPermissions>,
919    #[serde(default, skip_serializing_if = "Option::is_none")]
920    pub process: Option<PatternPermissions>,
921    #[serde(default, skip_serializing_if = "Option::is_none")]
922    pub env: Option<PatternPermissions>,
923    #[serde(
924        default,
925        rename = "hostFunction",
926        skip_serializing_if = "Option::is_none"
927    )]
928    pub host_function: Option<PatternPermissions>,
929}
930
931/// `"allow"` or `"deny"`.
932#[cfg_attr(feature = "contract", derive(ts_rs::TS))]
933#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
934#[serde(rename_all = "lowercase")]
935pub enum PermissionMode {
936    Allow,
937    Deny,
938}
939
940/// `PermissionMode | RulePermissions<FsPermissionRule>`.
941#[cfg_attr(feature = "contract", derive(ts_rs::TS))]
942#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
943#[serde(untagged)]
944pub enum FsPermissions {
945    Mode(PermissionMode),
946    Rules(RulePermissions<FsPermissionRule>),
947}
948
949/// `PermissionMode | RulePermissions<PatternPermissionRule>` (network/childProcess/process/env/binding).
950#[cfg_attr(feature = "contract", derive(ts_rs::TS))]
951#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
952#[serde(untagged)]
953pub enum PatternPermissions {
954    Mode(PermissionMode),
955    Rules(RulePermissions<PatternPermissionRule>),
956}
957
958/// `{ default?: PermissionMode; rules: T[] }`.
959#[cfg_attr(feature = "contract", derive(ts_rs::TS))]
960#[cfg_attr(feature = "contract", ts(rename_all = "camelCase"))]
961#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
962#[serde(deny_unknown_fields)]
963pub struct RulePermissions<T> {
964    #[serde(default, skip_serializing_if = "Option::is_none")]
965    pub default: Option<PermissionMode>,
966    pub rules: Vec<T>,
967}
968
969/// `{ mode; operations?; paths? }`.
970#[cfg_attr(feature = "contract", derive(ts_rs::TS))]
971#[cfg_attr(feature = "contract", ts(rename_all = "camelCase"))]
972#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
973#[serde(deny_unknown_fields)]
974pub struct FsPermissionRule {
975    pub mode: PermissionMode,
976    #[serde(default, skip_serializing_if = "Option::is_none")]
977    pub operations: Option<Vec<String>>,
978    #[serde(default, skip_serializing_if = "Option::is_none")]
979    pub paths: Option<Vec<String>>,
980}
981
982/// `{ mode; operations?; patterns? }`.
983#[cfg_attr(feature = "contract", derive(ts_rs::TS))]
984#[cfg_attr(feature = "contract", ts(rename_all = "camelCase"))]
985#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
986#[serde(deny_unknown_fields)]
987pub struct PatternPermissionRule {
988    pub mode: PermissionMode,
989    #[serde(default, skip_serializing_if = "Option::is_none")]
990    pub operations: Option<Vec<String>>,
991    #[serde(default, skip_serializing_if = "Option::is_none")]
992    pub patterns: Option<Vec<String>>,
993}
994
995// ---------------------------------------------------------------------------
996// Root filesystem (layers.ts / overlay-filesystem.ts)
997// ---------------------------------------------------------------------------
998
999/// Root filesystem configuration. Default: overlay + bundled base snapshot.
1000#[cfg_attr(feature = "contract", derive(ts_rs::TS))]
1001#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1002pub struct RootFilesystemConfig {
1003    #[serde(default, rename = "type")]
1004    pub kind: RootFilesystemKind,
1005    #[serde(default, skip_serializing_if = "Option::is_none")]
1006    pub mode: Option<RootFilesystemMode>,
1007    #[serde(
1008        default,
1009        rename = "nativePlugin",
1010        skip_serializing_if = "Option::is_none"
1011    )]
1012    pub native_plugin: Option<MountPlugin>,
1013    #[serde(default, rename = "disableDefaultBaseLayer")]
1014    pub disable_default_base_layer: bool,
1015    #[serde(default)]
1016    pub lowers: Vec<RootLowerInput>,
1017}
1018
1019impl Default for RootFilesystemConfig {
1020    fn default() -> Self {
1021        Self {
1022            kind: RootFilesystemKind::Overlay,
1023            mode: None,
1024            native_plugin: None,
1025            disable_default_base_layer: false,
1026            lowers: Vec::new(),
1027        }
1028    }
1029}
1030
1031/// The root filesystem kind.
1032#[cfg_attr(feature = "contract", derive(ts_rs::TS))]
1033#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
1034#[serde(rename_all = "lowercase")]
1035pub enum RootFilesystemKind {
1036    #[default]
1037    Overlay,
1038    Native,
1039}
1040
1041/// Root filesystem mode.
1042#[cfg_attr(feature = "contract", derive(ts_rs::TS))]
1043#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1044#[serde(rename_all = "kebab-case")]
1045pub enum RootFilesystemMode {
1046    Ephemeral,
1047    ReadOnly,
1048}
1049
1050/// A lower (immutable) snapshot layer input.
1051#[cfg_attr(feature = "contract", derive(ts_rs::TS))]
1052#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1053#[serde(tag = "kind", rename_all = "kebab-case")]
1054pub enum RootLowerInput {
1055    /// The bundled base filesystem snapshot.
1056    BundledBaseFilesystem,
1057    /// A snapshot export (`{ kind: "snapshot-export", source }`).
1058    #[serde(untagged)]
1059    SnapshotExport(crate::fs::RootSnapshotExport),
1060}
1061
1062// ---------------------------------------------------------------------------
1063// Mounts
1064// ---------------------------------------------------------------------------
1065
1066/// A filesystem mount. `Plain.driver` is an in-process trait object and cannot cross the wire.
1067pub enum MountConfig {
1068    /// Plain mount over an in-process [`VirtualFileSystem`] driver.
1069    Plain {
1070        path: String,
1071        driver: Arc<dyn VirtualFileSystem>,
1072        guest_source: Option<String>,
1073        guest_fstype: Option<String>,
1074        read_only: bool,
1075    },
1076    /// Native plugin mount (`{ id; config? }`).
1077    Native {
1078        path: String,
1079        plugin: MountPlugin,
1080        guest_source: Option<String>,
1081        guest_fstype: Option<String>,
1082        read_only: bool,
1083    },
1084    /// Overlay mount (`{ type: "overlay"; store; mode?; lowers }`).
1085    Overlay {
1086        path: String,
1087        filesystem: OverlayMountConfig,
1088    },
1089}
1090
1091/// A native mount plugin descriptor.
1092#[cfg_attr(feature = "contract", derive(ts_rs::TS))]
1093#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1094pub struct MountPlugin {
1095    pub id: String,
1096    #[serde(default, skip_serializing_if = "Option::is_none")]
1097    pub config: Option<serde_json::Value>,
1098}
1099
1100/// Mount a host `node_modules` directory into the VM at `/root/node_modules`.
1101///
1102/// Rust mirror of TS `nodeModulesMount(...)` (`packages/core/src/host-dir-mount.ts`).
1103/// This is the explicit, mount-based replacement for the removed `moduleAccessCwd`
1104/// option: the guest module resolver reads the mounted tree through the kernel VFS,
1105/// so the caller supplies exactly the `node_modules` directory whose packages should
1106/// be resolvable in the guest. The mount is read-only.
1107pub fn node_modules_mount(host_node_modules_dir: impl Into<String>) -> MountConfig {
1108    MountConfig::Native {
1109        path: "/root/node_modules".to_string(),
1110        plugin: MountPlugin {
1111            id: "host_dir".to_string(),
1112            config: Some(serde_json::json!({
1113                "hostPath": host_node_modules_dir.into(),
1114                "readOnly": true,
1115            })),
1116        },
1117        guest_source: Some(String::from("host_dir")),
1118        guest_fstype: Some(String::from("host_dir")),
1119        read_only: true,
1120    }
1121}
1122
1123/// Overlay mount filesystem config.
1124#[cfg_attr(feature = "contract", derive(ts_rs::TS))]
1125#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1126pub struct OverlayMountConfig {
1127    #[serde(rename = "type")]
1128    pub kind: String,
1129    pub store: serde_json::Value,
1130    #[serde(default, skip_serializing_if = "Option::is_none")]
1131    pub mode: Option<RootFilesystemMode>,
1132    pub lowers: Vec<RootLowerInput>,
1133}
1134
1135// ---------------------------------------------------------------------------
1136// Sidecar config
1137// ---------------------------------------------------------------------------
1138
1139/// How the client obtains its sidecar handle.
1140pub enum AgentOsSidecarConfig {
1141    /// Use (or create) a shared pooled sidecar (`pool` default `"default"`).
1142    Shared { pool: Option<String> },
1143    /// Use an explicit sidecar handle.
1144    Explicit {
1145        handle: Arc<crate::sidecar::AgentOsSidecar>,
1146    },
1147}
1148
1149// ---------------------------------------------------------------------------
1150// Schedule driver
1151// ---------------------------------------------------------------------------
1152
1153/// The callback fired by a [`ScheduleDriver`] when a schedule entry triggers.
1154///
1155/// Mirrors the TS `ScheduleEntry.callback: () => void | Promise<void>`. The cron manager passes a
1156/// closure that runs one job execution; the driver awaits it (and, for the default driver, reschedules
1157/// the next cron fire afterwards).
1158pub type ScheduleCallback = Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync>;
1159
1160/// A schedule entry handed to a [`ScheduleDriver`]. Mirrors TS `ScheduleEntry`
1161/// (`cron/schedule-driver.ts`).
1162#[derive(Clone)]
1163pub struct ScheduleEntry {
1164    /// Unique ID for this job.
1165    pub id: String,
1166    /// 5/6/7-field cron expression OR an ISO-8601 one-shot timestamp.
1167    pub schedule: String,
1168    /// Called when the schedule fires.
1169    pub callback: ScheduleCallback,
1170}
1171
1172/// Driver-owned scheduling abstraction. Mirrors the TS `ScheduleDriver` interface
1173/// (`cron/schedule-driver.ts`) exactly: the driver parses the schedule, arms the timer, reschedules
1174/// cron entries after each fire, and tears everything down on [`ScheduleDriver::dispose`]. This is the
1175/// documented extension point: a custom driver (deterministic virtual-time test driver, fire-immediately
1176/// driver, etc.) fully controls timing.
1177pub trait ScheduleDriver: Send + Sync {
1178    /// Schedule a callback to fire on a cron expression or at a specific time. Returns a cancellation
1179    /// handle.
1180    fn schedule(&self, entry: ScheduleEntry) -> ScheduleHandle;
1181
1182    /// Cancel a previously scheduled entry.
1183    fn cancel(&self, handle: &ScheduleHandle);
1184
1185    /// Tear down all scheduled work.
1186    fn dispose(&self);
1187}
1188
1189/// Handle to a scheduled entry. Mirrors TS `ScheduleHandle { id }`. Identifies the entry to cancel via
1190/// [`ScheduleDriver::cancel`].
1191#[derive(Clone)]
1192pub struct ScheduleHandle {
1193    pub id: String,
1194}
1195
1196/// Default schedule driver backed by `tokio` timers and the system clock.
1197///
1198/// Mirrors the TS `TimerScheduleDriver`: for cron expressions it computes the next fire time and arms
1199/// a single timer, rescheduling after each fire; for one-shot timestamps it fires once and removes the
1200/// entry. Driver-held timer tasks are tracked so [`ScheduleDriver::cancel`] / [`ScheduleDriver::dispose`]
1201/// can abort them.
1202#[derive(Default)]
1203pub struct TimerScheduleDriver {
1204    timers: Arc<scc::HashMap<String, tokio_util::sync::CancellationToken>>,
1205}
1206
1207impl TimerScheduleDriver {
1208    pub fn new() -> Self {
1209        Self {
1210            timers: Arc::new(scc::HashMap::new()),
1211        }
1212    }
1213
1214    /// Arm the next fire for `entry`. For a one-shot or an exhausted cron the entry is dropped. For a
1215    /// recurring cron the timer reschedules itself after firing the callback. `cancel` is the per-entry
1216    /// cancellation token shared with the registry slot.
1217    fn schedule_next(
1218        timers: Arc<scc::HashMap<String, tokio_util::sync::CancellationToken>>,
1219        entry: ScheduleEntry,
1220        cancel: tokio_util::sync::CancellationToken,
1221    ) {
1222        let now = chrono::Utc::now();
1223        let parsed = match crate::cron::parse_schedule(&entry.schedule) {
1224            Ok(parsed) => parsed,
1225            Err(_) => {
1226                let _ = timers.remove(&entry.id);
1227                return;
1228            }
1229        };
1230        let is_cron = parsed.is_cron();
1231        let next = match crate::cron::resolve_next_run(&parsed, now) {
1232            Some(next) => next,
1233            None => {
1234                // No upcoming run (one-shot in the past, or exhausted cron).
1235                let _ = timers.remove(&entry.id);
1236                return;
1237            }
1238        };
1239
1240        let delay = (next - now).to_std().unwrap_or(std::time::Duration::ZERO);
1241
1242        tokio::spawn(async move {
1243            tokio::select! {
1244                _ = cancel.cancelled() => {
1245                    return;
1246                }
1247                _ = tokio::time::sleep(delay) => {}
1248            }
1249            if cancel.is_cancelled() {
1250                return;
1251            }
1252            // The driver is fire-and-forget; errors are the caller's responsibility.
1253            (entry.callback)().await;
1254
1255            if is_cron && timers.contains(&entry.id) {
1256                Self::schedule_next(Arc::clone(&timers), entry, cancel);
1257            } else {
1258                let _ = timers.remove(&entry.id);
1259            }
1260        });
1261    }
1262}
1263
1264impl ScheduleDriver for TimerScheduleDriver {
1265    fn schedule(&self, entry: ScheduleEntry) -> ScheduleHandle {
1266        let id = entry.id.clone();
1267        let cancel = tokio_util::sync::CancellationToken::new();
1268        // Replace any existing timer for this id, cancelling it first.
1269        if let Some((_, old)) = self.timers.remove(&id) {
1270            old.cancel();
1271        }
1272        let _ = self.timers.insert(id.clone(), cancel.clone());
1273
1274        Self::schedule_next(Arc::clone(&self.timers), entry, cancel);
1275
1276        ScheduleHandle { id }
1277    }
1278
1279    fn cancel(&self, handle: &ScheduleHandle) {
1280        if let Some((_, cancel)) = self.timers.remove(&handle.id) {
1281            cancel.cancel();
1282        }
1283    }
1284
1285    fn dispose(&self) {
1286        self.timers.scan(|_, cancel| cancel.cancel());
1287        self.timers.clear();
1288    }
1289}