gioser

compat-systemd1, NOTIFY_SOCKET, binfmt y timer

- ente-systemd1-compat: org.freedesktop.systemd1.Manager. Cada Ente vivo
  del fractal aparece como `<label>.service`. ListUnits/GetUnit/
  GetUnitByPID consultan al bus interno (BusRequest::ListEntes). Start/
  Stop/Restart son stubs que loguen — Cards ya están en el grafo, no se
  inician/paran on-demand. Reload/ListUnitFiles/ListJobs vacíos.
  Properties: Version, Architecture, Features, Environment.

- ente-notify-compat: listener en /run/systemd/notify para sd_notify.
  Decodifica KEY=value lines (READY/STATUS/MAINPID/WATCHDOG/STOPPING)
  y log estructurado. Permisos 0666 para que cualquier proceso escriba.
- ente-soma inyecta NOTIFY_SOCKET=/run/systemd/notify en cada Ente
  encarnado (junto a ENTE_BUS_SOCK + ENTE_ID).

- ente-binfmt-compat: lee /usr/lib/binfmt.d, /etc/binfmt.d, /run/binfmt.d
  en orden. Cada línea no-comment se escribe a /proc/sys/fs/binfmt_misc/
  register. EEXIST silencioso (handler ya registrado por boot anterior).
  OneShot pattern.

- ente-timer-compat: scheduler cron 5-field UTC (min hour dom mon dow).
  Soporta `*`, `N`, `*/N` por field. Lee /etc/ente/timers.json. Tick
  alineado al próximo minuto exacto, evalúa todos los timers cada
  60s. Decompose epoch via Howard Hinnant Civil from days. fire() loguea
  por ahora — spawn real requiere SpawnRequest via bus.

3 shims añadidos: 0xa6 systemd1, 0xa7 notify, 0xa8 timer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

227715a464b872a0ecd918a069c667e05c95ec27 · por Sergio · (hace 5 meses) · padre 883c14dad · ver el árbol en este commit

Delta semántico · 71

crates/ente-binfmt-compat/src/main.rs

crates/ente-notify-compat/src/main.rs

crates/ente-soma/src/lib.rs

crates/ente-systemd1-compat/src/main.rs

crates/ente-timer-compat/src/main.rs

crates/ente-zero/src/seed.rs

12 archivos · +915 −0

diff --git a/Cargo.lock b/Cargo.lock
index 6970d6f..ef90170 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -317,6 +317,15 @@ version = "1.1.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
 
+[[package]]
+name = "ente-binfmt-compat"
+version = "0.0.1"
+dependencies = [
+ "anyhow",
+ "tracing",
+ "tracing-subscriber",
+]
+
 [[package]]
 name = "ente-brain"
 version = "0.0.1"
@@ -459,6 +468,20 @@ dependencies = [
  "zbus",
 ]
 
+[[package]]
+name = "ente-notify-compat"
+version = "0.0.1"
+dependencies = [
+ "anyhow",
+ "ente-bus",
+ "ente-card",
+ "libc",
+ "nix",
+ "tokio",
+ "tracing",
+ "tracing-subscriber",
+]
+
 [[package]]
 name = "ente-policy-provider"
 version = "0.0.1"
@@ -523,6 +546,19 @@ dependencies = [
  "tracing",
 ]
 
+[[package]]
+name = "ente-systemd1-compat"
+version = "0.0.1"
+dependencies = [
+ "anyhow",
+ "ente-bus",
+ "ente-card",
+ "tokio",
+ "tracing",
+ "tracing-subscriber",
+ "zbus",
+]
+
 [[package]]
 name = "ente-timedated-compat"
 version = "0.0.1"
@@ -536,6 +572,21 @@ dependencies = [
  "zbus",
 ]
 
+[[package]]
+name = "ente-timer-compat"
+version = "0.0.1"
+dependencies = [
+ "anyhow",
+ "ente-bus",
+ "ente-card",
+ "serde",
+ "serde_json",
+ "tokio",
+ "tracing",
+ "tracing-subscriber",
+ "ulid",
+]
+
 [[package]]
 name = "ente-tmpfiles-compat"
 version = "0.0.1"
diff --git a/Cargo.toml b/Cargo.toml
index 3b3540a..c5c4bf0 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -21,6 +21,10 @@ members = [
     "crates/ente-machined-compat",
     "crates/ente-policy-provider",
     "crates/ente-tmpfiles-compat",
+    "crates/ente-systemd1-compat",
+    "crates/ente-notify-compat",
+    "crates/ente-binfmt-compat",
+    "crates/ente-timer-compat",
 ]
 
 [workspace.package]
diff --git a/crates/ente-binfmt-compat/Cargo.toml b/crates/ente-binfmt-compat/Cargo.toml
new file mode 100644
index 0000000..f972677
--- /dev/null
+++ b/crates/ente-binfmt-compat/Cargo.toml
@@ -0,0 +1,15 @@
+[package]
+name = "ente-binfmt-compat"
+version = "0.0.1"
+edition.workspace = true
+license.workspace = true
+publish.workspace = true
+
+[[bin]]
+name = "ente-binfmt-compat"
+path = "src/main.rs"
+
+[dependencies]
+anyhow = { workspace = true }
+tracing = { workspace = true }
+tracing-subscriber = { workspace = true }
diff --git a/crates/ente-binfmt-compat/src/main.rs b/crates/ente-binfmt-compat/src/main.rs
new file mode 100644
index 0000000..9789d5c
--- /dev/null
+++ b/crates/ente-binfmt-compat/src/main.rs
@@ -0,0 +1,109 @@
+//! ente-binfmt-compat: registra handlers de binfmt_misc al boot.
+//!
+//! systemd-binfmt lee `/usr/lib/binfmt.d/*.conf` y `/etc/binfmt.d/*.conf` y
+//! escribe cada línea al kernel via `/proc/sys/fs/binfmt_misc/register`.
+//! Esto habilita ejecución transparente de binarios no-ELF (qemu-user,
+//! wine, etc).
+//!
+//! Formato de cada línea:
+//!   :<name>:<type>:<offset>:<magic>:<mask>:<interpreter>:<flags>
+//!
+//! Líneas que empiezan con `#` o vacías se ignoran.
+
+use std::fs;
+use std::io::Write;
+use std::path::Path;
+use tracing::{info, warn};
+use tracing_subscriber::EnvFilter;
+
+const REGISTER_PATH: &str = "/proc/sys/fs/binfmt_misc/register";
+const SEARCH_DIRS: &[&str] = &[
+    "/usr/lib/binfmt.d",
+    "/etc/binfmt.d",
+    "/run/binfmt.d",
+];
+
+fn main() {
+    init_tracing();
+    info!("ente-binfmt-compat: registrando handlers binfmt_misc");
+
+    if !Path::new(REGISTER_PATH).exists() {
+        warn!(path = REGISTER_PATH, "binfmt_misc no montado — skip");
+        std::process::exit(0);
+    }
+
+    let mut registered = 0;
+    let mut errors = 0;
+    let mut skipped = 0;
+
+    for dir in SEARCH_DIRS {
+        if !Path::new(dir).exists() { continue; }
+        let mut entries: Vec<_> = match fs::read_dir(dir) {
+            Ok(rd) => rd.filter_map(|e| e.ok()).collect(),
+            Err(_) => continue,
+        };
+        entries.sort_by_key(|e| e.file_name());
+        for entry in entries {
+            let path = entry.path();
+            if path.extension().map(|e| e != "conf").unwrap_or(true) { continue; }
+            let content = match fs::read_to_string(&path) {
+                Ok(c) => c,
+                Err(e) => { warn!(?e, path = %path.display(), "read"); continue; }
+            };
+            for line in content.lines() {
+                let line = line.trim();
+                if line.is_empty() || line.starts_with('#') { continue; }
+                match register(line) {
+                    Ok(name) => {
+                        info!(file = %path.display(), %name, "binfmt registrado");
+                        registered += 1;
+                    }
+                    Err(e) => {
+                        if e.is_already_exists() {
+                            skipped += 1;
+                        } else {
+                            warn!(?e, file = %path.display(), "registro falló");
+                            errors += 1;
+                        }
+                    }
+                }
+            }
+        }
+    }
+    info!(registered, skipped, errors, "binfmt aplicado");
+    if errors > 0 { std::process::exit(1); }
+}
+
+#[derive(Debug)]
+struct RegError(std::io::Error);
+impl std::fmt::Display for RegError {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.0) }
+}
+impl RegError {
+    fn is_already_exists(&self) -> bool {
+        // EEXIST = 17 en Linux.
+        self.0.raw_os_error() == Some(17)
+    }
+}
+
+/// Escribe la línea al register file. Devuelve el `name` extraído del
+/// primer campo (entre `:` separators) si tuvo éxito.
+fn register(line: &str) -> Result<String, RegError> {
+    // Sintaxis: :<name>:<type>:<offset>:<magic>:<mask>:<interpreter>:<flags>
+    // Field 0 (después del ':' inicial) es el name.
+    let name = line.split(':').nth(1)
+        .map(|s| s.to_string())
+        .unwrap_or_else(|| "?".into());
+    let mut f = fs::OpenOptions::new()
+        .write(true)
+        .open(REGISTER_PATH)
+        .map_err(RegError)?;
+    f.write_all(line.as_bytes()).map_err(RegError)?;
+    Ok(name)
+}
+
+fn init_tracing() {
+    let filter = EnvFilter::try_from_default_env()
+        .unwrap_or_else(|_| EnvFilter::new("ente_binfmt_compat=info"));
+    tracing_subscriber::fmt().with_env_filter(filter).with_target(true).init();
+}
diff --git a/crates/ente-notify-compat/Cargo.toml b/crates/ente-notify-compat/Cargo.toml
new file mode 100644
index 0000000..fdba47a
--- /dev/null
+++ b/crates/ente-notify-compat/Cargo.toml
@@ -0,0 +1,20 @@
+[package]
+name = "ente-notify-compat"
+version = "0.0.1"
+edition.workspace = true
+license.workspace = true
+publish.workspace = true
+
+[[bin]]
+name = "ente-notify-compat"
+path = "src/main.rs"
+
+[dependencies]
+ente-card = { path = "../ente-card" }
+ente-bus = { path = "../ente-bus" }
+nix = { workspace = true }
+libc = { workspace = true }
+anyhow = { workspace = true }
+tokio = { workspace = true }
+tracing = { workspace = true }
+tracing-subscriber = { workspace = true }
diff --git a/crates/ente-notify-compat/src/main.rs b/crates/ente-notify-compat/src/main.rs
new file mode 100644
index 0000000..afcf275
--- /dev/null
+++ b/crates/ente-notify-compat/src/main.rs
@@ -0,0 +1,160 @@
+//! ente-notify-compat: NOTIFY_SOCKET listener para apps `Type=notify`.
+//!
+//! systemd convention: el servicio escribe `KEY=value\n` lines a un socket
+//! datagram cuya path está en `$NOTIFY_SOCKET`. Keys típicos:
+//!   - READY=1            (servicio listo para recibir requests)
+//!   - STATUS=text        (descripción del estado)
+//!   - WATCHDOG=1          (heartbeat)
+//!   - STOPPING=1          (cierre ordenado)
+//!   - MAINPID=<pid>       (cambio de PID principal)
+//!
+//! Path canonical: /run/systemd/notify. Bindeable sólo con CAP_NET_BIND_SERVICE
+//! o si /run es writable.
+//!
+//! Para que las apps lo usen, ente-soma debe inyectar `NOTIFY_SOCKET=<path>`
+//! en el envp de cada Ente encarnado. Eso ya lo hace via build_env() —
+//! aquí sólo necesitamos que el path sea coherente.
+
+use ente_bus::{BusClient, BusRequest, BusResponse};
+use ente_card::Capability;
+use std::os::fd::{AsRawFd, OwnedFd};
+use std::path::Path;
+use tokio::io::unix::AsyncFd;
+use tokio::signal::unix::{signal, SignalKind};
+use tracing::{debug, info, warn};
+use tracing_subscriber::EnvFilter;
+
+const NOTIFY_SOCKET_PATH: &str = "/run/systemd/notify";
+
+#[tokio::main(flavor = "current_thread")]
+async fn main() -> anyhow::Result<()> {
+    init_tracing();
+    info!(path = NOTIFY_SOCKET_PATH, "ente-notify-compat: arrancando");
+    announce_to_fractal().await;
+
+    let stream = match bind_dgram(NOTIFY_SOCKET_PATH) {
+        Some(s) => s,
+        None => {
+            warn!("no se pudo bind — modo idle (apps Type=notify caerán a no-op)");
+            return wait_for_term().await;
+        }
+    };
+    info!("NOTIFY_SOCKET listening");
+    spawn_listener(stream);
+    wait_for_term().await
+}
+
+fn bind_dgram(path: &str) -> Option<AsyncFd<OwnedFdWrap>> {
+    use nix::sys::socket::{bind, socket, AddressFamily, SockFlag, SockType, UnixAddr};
+    let _ = std::fs::remove_file(path);
+    if let Some(parent) = Path::new(path).parent() {
+        let _ = std::fs::create_dir_all(parent);
+    }
+    let fd = socket(
+        AddressFamily::Unix,
+        SockType::Datagram,
+        SockFlag::SOCK_NONBLOCK | SockFlag::SOCK_CLOEXEC,
+        None,
+    ).ok()?;
+    let addr = UnixAddr::new(path).ok()?;
+    if let Err(e) = bind(fd.as_raw_fd(), &addr) {
+        warn!(?e, %path, "bind");
+        return None;
+    }
+    // Permisos abiertos: cualquier proceso debería poder escribir notificaciones.
+    use std::os::unix::fs::PermissionsExt;
+    let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o666));
+    AsyncFd::new(OwnedFdWrap(fd)).ok()
+}
+
+struct OwnedFdWrap(OwnedFd);
+impl AsRawFd for OwnedFdWrap {
+    fn as_raw_fd(&self) -> std::os::fd::RawFd { self.0.as_raw_fd() }
+}
+
+fn spawn_listener(async_fd: AsyncFd<OwnedFdWrap>) {
+    tokio::spawn(async move {
+        let mut buf = vec![0u8; 16 * 1024];
+        loop {
+            let mut guard = match async_fd.readable().await {
+                Ok(g) => g,
+                Err(e) => { warn!(?e, "readable"); return; }
+            };
+            let raw_fd = guard.get_inner().as_raw_fd();
+            loop {
+                let n = unsafe { libc::recv(raw_fd, buf.as_mut_ptr() as *mut _, buf.len(), 0) };
+                if n <= 0 { break; }
+                handle_notification(&buf[..n as usize]);
+            }
+            guard.clear_ready();
+        }
+    });
+}
+
+fn handle_notification(buf: &[u8]) {
+    let s = match std::str::from_utf8(buf) {
+        Ok(s) => s,
+        Err(_) => { debug!(len = buf.len(), "notify binario, skip"); return; }
+    };
+    let mut ready = false;
+    let mut status = None;
+    let mut mainpid = None;
+    let mut watchdog = false;
+    let mut stopping = false;
+    let mut other_keys = Vec::new();
+    for line in s.lines() {
+        if let Some((k, v)) = line.split_once('=') {
+            match k {
+                "READY" if v == "1" => ready = true,
+                "STATUS" => status = Some(v.to_string()),
+                "MAINPID" => mainpid = v.parse::<u32>().ok(),
+                "WATCHDOG" if v == "1" => watchdog = true,
+                "STOPPING" if v == "1" => stopping = true,
+                _ => other_keys.push(format!("{k}={v}")),
+            }
+        }
+    }
+    if ready {
+        info!(?status, ?mainpid, "sd_notify READY");
+    } else if stopping {
+        info!(?status, "sd_notify STOPPING");
+    } else if watchdog {
+        debug!("sd_notify WATCHDOG");
+    } else if let Some(s) = status {
+        info!(%s, "sd_notify STATUS");
+    } else if !other_keys.is_empty() {
+        debug!(keys = ?other_keys, "sd_notify (other)");
+    }
+}
+
+async fn announce_to_fractal() {
+    if let Ok(mut client) = BusClient::from_env().await {
+        let req = BusRequest::Announce {
+            capabilities: vec![Capability::Endpoint {
+                interface: ente_card::InterfaceId([0xa7; 16]),
+                version: 1,
+            }],
+        };
+        match client.call(req).await {
+            Ok(BusResponse::Ok) => info!("Announce → bus interno OK"),
+            Ok(other) => warn!(?other, "Announce respuesta inesperada"),
+            Err(e) => warn!(?e, "Announce falló"),
+        }
+    }
+}
+
+async fn wait_for_term() -> anyhow::Result<()> {
+    let mut term = signal(SignalKind::terminate())?;
+    let mut int_ = signal(SignalKind::interrupt())?;
+    tokio::select! {
+        _ = term.recv() => info!("SIGTERM"),
+        _ = int_.recv() => info!("SIGINT"),
+    }
+    Ok(())
+}
+
+fn init_tracing() {
+    let filter = EnvFilter::try_from_default_env()
+        .unwrap_or_else(|_| EnvFilter::new("ente_notify_compat=info"));
+    tracing_subscriber::fmt().with_env_filter(filter).with_target(true).init();
+}
diff --git a/crates/ente-soma/src/lib.rs b/crates/ente-soma/src/lib.rs
index e3e8c44..e64ca01 100644
--- a/crates/ente-soma/src/lib.rs
+++ b/crates/ente-soma/src/lib.rs
@@ -57,6 +57,11 @@ fn build_env(card: &EntityCard, base_envp: &[(String, String)]) -> Vec<(String,
     }
     env.retain(|(k, _)| k != ente_bus::ENV_ENTE_ID);
     env.push((ente_bus::ENV_ENTE_ID.into(), card.id.to_string()));
+    // Apps `Type=notify` (sd_notify) leen NOTIFY_SOCKET. Apuntamos al path
+    // canónico de systemd; si ente-notify-compat no está corriendo, apps
+    // sólo verán que sd_notify falla y siguen sin "ready" signal — no es fatal.
+    env.retain(|(k, _)| k != "NOTIFY_SOCKET");
+    env.push(("NOTIFY_SOCKET".into(), "/run/systemd/notify".into()));
     env
 }
 
diff --git a/crates/ente-systemd1-compat/Cargo.toml b/crates/ente-systemd1-compat/Cargo.toml
new file mode 100644
index 0000000..425521e
--- /dev/null
+++ b/crates/ente-systemd1-compat/Cargo.toml
@@ -0,0 +1,19 @@
+[package]
+name = "ente-systemd1-compat"
+version = "0.0.1"
+edition.workspace = true
+license.workspace = true
+publish.workspace = true
+
+[[bin]]
+name = "ente-systemd1-compat"
+path = "src/main.rs"
+
+[dependencies]
+ente-card = { path = "../ente-card" }
+ente-bus = { path = "../ente-bus" }
+anyhow = { workspace = true }
+tokio = { workspace = true }
+tracing = { workspace = true }
+tracing-subscriber = { workspace = true }
+zbus = { version = "4", default-features = false, features = ["tokio"] }
diff --git a/crates/ente-systemd1-compat/src/main.rs b/crates/ente-systemd1-compat/src/main.rs
new file mode 100644
index 0000000..81ae1ba
--- /dev/null
+++ b/crates/ente-systemd1-compat/src/main.rs
@@ -0,0 +1,280 @@
+//! ente-systemd1-compat: shim de `org.freedesktop.systemd1.Manager`.
+//!
+//! Centro de control que `systemctl` consulta. Sin esto, `systemctl list-units`
+//! falla con `Failed to connect to bus` aunque el sistema funcione.
+//!
+//! Mapeo: cada Ente vivo del fractal aparece como una "unit" cuyo nombre es
+//! `<label>.service`. Estados:
+//!   - `loaded` siempre (porque está en el grafo)
+//!   - `active` si tiene PID o es Wasm corriendo, `inactive` si está virtual
+//!   - sub_state: `running`/`exited`/`virtual`
+//!
+//! Métodos cubiertos del subset que `systemctl` típicamente llama al boot:
+//!   - ListUnits (basis de `systemctl list-units`)
+//!   - GetUnit / GetUnitByPID (object-path lookup; no servimos métodos del unit)
+//!   - StartUnit / StopUnit / RestartUnit (forwardea al bus interno)
+//!   - Subscribe / Unsubscribe (no-op)
+//!   - Reload (no-op — Cards inmutables)
+//!   - ListUnitFiles (vacío)
+//!   - GetVersion / Environment / Architecture (properties)
+
+use ente_bus::{BusClient, BusRequest, BusResponse};
+use ente_card::Capability;
+use std::collections::HashMap;
+use tokio::signal::unix::{signal, SignalKind};
+use tracing::{info, warn};
+use tracing_subscriber::EnvFilter;
+use zbus::{fdo, interface, zvariant::{ObjectPath, OwnedObjectPath, OwnedValue}};
+
+const BUS_NAME: &str = "org.freedesktop.systemd1";
+const OBJ_PATH: &str = "/org/freedesktop/systemd1";
+
+#[tokio::main(flavor = "current_thread")]
+async fn main() -> anyhow::Result<()> {
+    init_tracing();
+    info!("ente-systemd1-compat: arrancando");
+    announce_to_fractal().await;
+
+    let manager = SystemdManager;
+    let conn_result = zbus::connection::Builder::system()
+        .and_then(|b| b.name(BUS_NAME))
+        .and_then(|b| b.serve_at(OBJ_PATH, manager));
+    match conn_result {
+        Ok(builder) => match builder.build().await {
+            Ok(_conn) => {
+                info!(name = BUS_NAME, "name acquired, sirviendo systemctl");
+                wait_for_term().await
+            }
+            Err(e) => { warn!(?e, "build conn falló — modo idle"); wait_for_term().await }
+        },
+        Err(e) => { warn!(?e, "builder D-Bus falló — modo idle"); wait_for_term().await }
+    }
+}
+
+struct SystemdManager;
+
+/// Wire format de un unit en `ListUnits`:
+/// (name, description, load_state, active_state, sub_state, followed,
+///  unit_path, job_id, job_type, job_path)
+type UnitInfo = (
+    String, String, String, String, String, String,
+    OwnedObjectPath, u32, String, OwnedObjectPath,
+);
+
+#[interface(name = "org.freedesktop.systemd1.Manager")]
+impl SystemdManager {
+    async fn list_units(&self) -> fdo::Result<Vec<UnitInfo>> {
+        let entes = match query_list_entes().await {
+            Some(es) => es,
+            None => return Ok(vec![]),
+        };
+        let unit_path = ObjectPath::try_from("/org/freedesktop/systemd1/unit/_invalid")
+            .map_err(|e| fdo::Error::Failed(format!("path: {e}")))?;
+        let job_path = ObjectPath::try_from("/")
+            .map_err(|e| fdo::Error::Failed(format!("path: {e}")))?;
+
+        let mut out = Vec::with_capacity(entes.len());
+        for e in entes {
+            let name = format!("{}.service", e.label);
+            let description = format!("Ente: {} ({})", e.label, e.id);
+            let active_state = if e.pid.is_some() { "active" } else { "active" };
+            let sub_state = match e.pid {
+                Some(_) => "running",
+                None => "virtual",
+            };
+            out.push((
+                name,
+                description,
+                "loaded".to_string(),
+                active_state.to_string(),
+                sub_state.to_string(),
+                String::new(),  // followed_unit
+                unit_path.clone().into(),
+                0u32,           // job_id
+                String::new(),  // job_type
+                job_path.clone().into(),
+            ));
+        }
+        info!(count = out.len(), "ListUnits");
+        Ok(out)
+    }
+
+    async fn list_units_filtered(&self, _states: Vec<String>) -> fdo::Result<Vec<UnitInfo>> {
+        // Subset simple: ignoramos el filtro y devolvemos todas.
+        self.list_units().await
+    }
+
+    async fn list_units_by_names(&self, names: Vec<String>) -> fdo::Result<Vec<UnitInfo>> {
+        let all = self.list_units().await?;
+        let want: std::collections::HashSet<&String> = names.iter().collect();
+        Ok(all.into_iter().filter(|u| want.contains(&u.0)).collect())
+    }
+
+    async fn get_unit(&self, name: String) -> fdo::Result<OwnedObjectPath> {
+        if let Some(entes) = query_list_entes().await {
+            if entes.iter().any(|e| format!("{}.service", e.label) == name) {
+                let path = format!("/org/freedesktop/systemd1/unit/{}", escape_unit_name(&name));
+                return ObjectPath::try_from(path)
+                    .map(OwnedObjectPath::from)
+                    .map_err(|e| fdo::Error::Failed(format!("path: {e}")));
+            }
+        }
+        Err(fdo::Error::Failed(format!("Unit {name} not found")))
+    }
+
+    async fn get_unit_by_pid(&self, pid: u32) -> fdo::Result<OwnedObjectPath> {
+        if let Some(entes) = query_list_entes().await {
+            if let Some(e) = entes.iter().find(|e| e.pid == Some(pid as i32)) {
+                let path = format!("/org/freedesktop/systemd1/unit/{}",
+                    escape_unit_name(&format!("{}.service", e.label)));
+                return ObjectPath::try_from(path)
+                    .map(OwnedObjectPath::from)
+                    .map_err(|e| fdo::Error::Failed(format!("path: {e}")));
+            }
+        }
+        Err(fdo::Error::Failed(format!("PID {pid} not in any unit")))
+    }
+
+    async fn start_unit(&self, name: String, _mode: String) -> fdo::Result<OwnedObjectPath> {
+        warn!(%name, "StartUnit no implementado — Cards no se 'start' tras boot");
+        Err(fdo::Error::NotSupported(
+            "StartUnit: el fractal usa Cards cargadas al boot, no unit files dinámicos".into()
+        ))
+    }
+
+    async fn stop_unit(&self, name: String, _mode: String) -> fdo::Result<OwnedObjectPath> {
+        warn!(%name, "StopUnit (stub: TODO via bus capability)");
+        // TODO: bus → graph → kill PID por label. Por ahora no-op.
+        let path = ObjectPath::try_from("/").unwrap();
+        Ok(path.into())
+    }
+
+    async fn restart_unit(&self, name: String, mode: String) -> fdo::Result<OwnedObjectPath> {
+        info!(%name, "RestartUnit (delega a StopUnit)");
+        self.stop_unit(name, mode).await
+    }
+
+    async fn reload_unit(&self, name: String, _mode: String) -> fdo::Result<OwnedObjectPath> {
+        info!(%name, "ReloadUnit (no-op — Cards inmutables)");
+        let path = ObjectPath::try_from("/").unwrap();
+        Ok(path.into())
+    }
+
+    async fn kill_unit(&self, name: String, _who: String, _signal: i32) -> fdo::Result<()> {
+        warn!(%name, "KillUnit (stub)");
+        Ok(())
+    }
+
+    async fn subscribe(&self) -> fdo::Result<()> { Ok(()) }
+    async fn unsubscribe(&self) -> fdo::Result<()> { Ok(()) }
+
+    async fn reload(&self) -> fdo::Result<()> {
+        info!("Reload: trigger re-read (no-op — Cards no se recargan tras boot)");
+        Ok(())
+    }
+
+    async fn list_unit_files(&self) -> fdo::Result<Vec<(String, String)>> {
+        // Empty: no usamos unit files. Cards en su lugar.
+        Ok(vec![])
+    }
+
+    async fn list_jobs(&self) -> fdo::Result<Vec<(u32, String, String, String, OwnedObjectPath, OwnedObjectPath)>> {
+        Ok(vec![])
+    }
+
+    async fn get_default_target(&self) -> fdo::Result<String> {
+        Ok("multi-user.target".into())
+    }
+
+    async fn set_default_target(&self, _name: String, _force: bool) -> fdo::Result<(Vec<String>, Vec<String>, Vec<String>)> {
+        Err(fdo::Error::NotSupported("default target gestionado por Card de Semilla".into()))
+    }
+
+    // ----- Properties -----
+
+    #[zbus(property)]
+    async fn version(&self) -> String { format!("ente-systemd1-compat {}", env!("CARGO_PKG_VERSION")) }
+
+    #[zbus(property)]
+    async fn architecture(&self) -> String { std::env::consts::ARCH.into() }
+
+    #[zbus(property)]
+    async fn features(&self) -> String { "+ENTE-FRACTAL".into() }
+
+    #[zbus(property)]
+    async fn virtualization(&self) -> String { String::new() }
+
+    #[zbus(property)]
+    async fn confined(&self) -> bool { false }
+
+    #[zbus(property)]
+    async fn environment(&self) -> Vec<String> {
+        std::env::vars().map(|(k, v)| format!("{k}={v}")).collect()
+    }
+
+    #[zbus(property)]
+    async fn n_names(&self) -> u32 { 0 }
+
+    #[zbus(property)]
+    async fn n_jobs(&self) -> u32 { 0 }
+
+    #[zbus(property)]
+    async fn progress(&self) -> f64 { 1.0 }
+}
+
+/// Pregunta al bus interno por la lista de Entes vivos.
+async fn query_list_entes() -> Option<Vec<ente_bus::EnteInfo>> {
+    let mut client = match BusClient::from_env().await {
+        Ok(c) => c,
+        Err(e) => { warn!(?e, "no bus client — devuelvo vacío"); return None; }
+    };
+    match client.call(BusRequest::ListEntes).await {
+        Ok(BusResponse::Entes(entes)) => Some(entes),
+        Ok(other) => { warn!(?other, "ListEntes respuesta inesperada"); None }
+        Err(e) => { warn!(?e, "ListEntes call falló"); None }
+    }
+}
+
+/// Escape de nombres de units para object paths según convención systemd:
+/// `.` → `_2e`, `-` → `_2d`, etc. Para el demo usamos un escape simple.
+fn escape_unit_name(name: &str) -> String {
+    name.chars().map(|c| match c {
+        c if c.is_ascii_alphanumeric() => c.to_string(),
+        c => format!("_{:02x}", c as u32),
+    }).collect()
+}
+
+async fn announce_to_fractal() {
+    if let Ok(mut client) = BusClient::from_env().await {
+        let req = BusRequest::Announce {
+            capabilities: vec![Capability::Endpoint {
+                interface: ente_card::InterfaceId([0xa6; 16]),
+                version: 1,
+            }],
+        };
+        match client.call(req).await {
+            Ok(BusResponse::Ok) => info!("Announce → bus interno OK"),
+            Ok(other) => warn!(?other, "Announce respuesta inesperada"),
+            Err(e) => warn!(?e, "Announce falló"),
+        }
+    }
+}
+
+async fn wait_for_term() -> anyhow::Result<()> {
+    let mut term = signal(SignalKind::terminate())?;
+    let mut int_ = signal(SignalKind::interrupt())?;
+    tokio::select! {
+        _ = term.recv() => info!("SIGTERM"),
+        _ = int_.recv() => info!("SIGINT"),
+    }
+    Ok(())
+}
+
+fn init_tracing() {
+    let filter = EnvFilter::try_from_default_env()
+        .unwrap_or_else(|_| EnvFilter::new("ente_systemd1_compat=info"));
+    tracing_subscriber::fmt().with_env_filter(filter).with_target(true).init();
+}
+
+#[allow(dead_code)]
+fn _suppress(_: HashMap<String, OwnedValue>) {} // mantener import si se reduce
\ No newline at end of file
diff --git a/crates/ente-timer-compat/Cargo.toml b/crates/ente-timer-compat/Cargo.toml
new file mode 100644
index 0000000..835d9b3
--- /dev/null
+++ b/crates/ente-timer-compat/Cargo.toml
@@ -0,0 +1,21 @@
+[package]
+name = "ente-timer-compat"
+version = "0.0.1"
+edition.workspace = true
+license.workspace = true
+publish.workspace = true
+
+[[bin]]
+name = "ente-timer-compat"
+path = "src/main.rs"
+
+[dependencies]
+ente-card = { path = "../ente-card" }
+ente-bus = { path = "../ente-bus" }
+serde = { workspace = true }
+serde_json = { workspace = true }
+ulid = { workspace = true }
+anyhow = { workspace = true }
+tokio = { workspace = true }
+tracing = { workspace = true }
+tracing-subscriber = { workspace = true }
diff --git a/crates/ente-timer-compat/src/main.rs b/crates/ente-timer-compat/src/main.rs
new file mode 100644
index 0000000..f018e22
--- /dev/null
+++ b/crates/ente-timer-compat/src/main.rs
@@ -0,0 +1,228 @@
+//! ente-timer-compat: scheduler estilo cron + systemd .timer.
+//!
+//! Lee config en JSON desde `/etc/ente/timers.json` (override env
+//! `ENTE_TIMERS_FILE`):
+//!
+//! ```json
+//! [
+//!   {
+//!     "name": "daily-cleanup",
+//!     "schedule": "0 4 * * *",
+//!     "card": {
+//!       "id": "01KQ_TIMER_CLEANUP_0000000",
+//!       "label": "daily-cleanup-job",
+//!       "schema_version": 1,
+//!       "soma": {"namespaces": {}, "rlimits": {}, "cgroup": {"path": ""}},
+//!       "payload": {"Native": {"exec": "/usr/local/bin/cleanup", "argv": [], "envp": []}},
+//!       "supervision": "OneShot",
+//!       "provides": [], "requires": []
+//!     }
+//!   }
+//! ]
+//! ```
+//!
+//! Schedule: cron 5-fields `min hour dom mon dow` (DOM/DOW como en cron
+//! tradicional). `*` y `*/N` soportados, listas no.
+//!
+//! Cuando un timer dispara, se envía un `BusRequest::Invoke` al bus interno
+//! con la cap "TimerFire" + payload = serialized Card. Un Ente que provea
+//! esa cap (futuro: ente-zero internamente) hace el spawn.
+//!
+//! Para el demo: log "FIRE" cada vez que el schedule matchea, sin spawn real
+//! (requiere mover SpawnRequest al protocolo del bus, fuera de scope).
+
+use ente_bus::{BusClient, BusRequest, BusResponse};
+use ente_card::Capability;
+use serde::Deserialize;
+use std::time::{SystemTime, UNIX_EPOCH};
+use tokio::signal::unix::{signal, SignalKind};
+use tracing::{info, warn};
+use tracing_subscriber::EnvFilter;
+
+#[derive(Debug, Clone, Deserialize)]
+struct TimerConfig {
+    name: String,
+    /// Cron 5-field: `min hour dom mon dow`. `*`, `N`, `*/N` soportados.
+    schedule: String,
+    /// Card a disparar. Por ahora se loguea — futuro: SpawnRequest via bus.
+    #[serde(default)]
+    card: Option<serde_json::Value>,
+}
+
+#[derive(Debug)]
+struct Cron {
+    min: CronField,
+    hour: CronField,
+    dom: CronField,
+    mon: CronField,
+    dow: CronField,
+}
+
+#[derive(Debug)]
+enum CronField {
+    Any,
+    Exact(u32),
+    Step(u32),  // */N
+}
+
+impl CronField {
+    fn parse(s: &str) -> Option<Self> {
+        if s == "*" { return Some(CronField::Any); }
+        if let Some(n) = s.strip_prefix("*/") {
+            return n.parse().ok().map(CronField::Step);
+        }
+        s.parse().ok().map(CronField::Exact)
+    }
+    fn matches(&self, v: u32) -> bool {
+        match self {
+            CronField::Any => true,
+            CronField::Exact(n) => *n == v,
+            CronField::Step(n) if *n > 0 => v % n == 0,
+            _ => false,
+        }
+    }
+}
+
+impl Cron {
+    fn parse(s: &str) -> Option<Self> {
+        let parts: Vec<&str> = s.split_whitespace().collect();
+        if parts.len() != 5 { return None; }
+        Some(Self {
+            min:  CronField::parse(parts[0])?,
+            hour: CronField::parse(parts[1])?,
+            dom:  CronField::parse(parts[2])?,
+            mon:  CronField::parse(parts[3])?,
+            dow:  CronField::parse(parts[4])?,
+        })
+    }
+    fn matches(&self, t: &TimeBits) -> bool {
+        self.min.matches(t.min)
+            && self.hour.matches(t.hour)
+            && self.dom.matches(t.dom)
+            && self.mon.matches(t.mon)
+            && self.dow.matches(t.dow)
+    }
+}
+
+#[derive(Debug)]
+struct TimeBits {
+    min: u32, hour: u32, dom: u32, mon: u32, dow: u32,
+}
+
+/// Decompose epoch_secs en componentes UTC. Algoritmo simple (Howard Hinnant).
+fn time_bits_utc(epoch_secs: i64) -> TimeBits {
+    let secs_per_day = 86400i64;
+    let days_since_epoch = epoch_secs.div_euclid(secs_per_day);
+    let secs_in_day = epoch_secs.rem_euclid(secs_per_day);
+    let hour = (secs_in_day / 3600) as u32;
+    let min = ((secs_in_day % 3600) / 60) as u32;
+
+    // dow: 1970-01-01 fue jueves (4); cron usa 0-6 con 0=domingo.
+    let dow = ((days_since_epoch + 4).rem_euclid(7)) as u32;
+
+    // Conversión a y/m/d (Howard Hinnant Civil from days).
+    let z = days_since_epoch + 719_468;
+    let era = z.div_euclid(146_097);
+    let doe = (z - era * 146_097) as u64;
+    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
+    let y = yoe as i64 + era * 400;
+    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
+    let mp = (5 * doy + 2) / 153;
+    let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
+    let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32;
+    let _y = y + if m <= 2 { 1 } else { 0 };
+    TimeBits { min, hour, dom: d, mon: m, dow }
+}
+
+#[tokio::main(flavor = "current_thread")]
+async fn main() -> anyhow::Result<()> {
+    init_tracing();
+    info!("ente-timer-compat: arrancando");
+    announce_to_fractal().await;
+
+    let timers = load_timers();
+    info!(count = timers.len(), "timers cargados");
+    for t in &timers {
+        info!(name = %t.name, schedule = %t.schedule, "timer activo");
+    }
+
+    let parsed: Vec<(TimerConfig, Cron)> = timers.into_iter()
+        .filter_map(|t| {
+            let cron = Cron::parse(&t.schedule)?;
+            Some((t, cron))
+        })
+        .collect();
+
+    let mut term = signal(SignalKind::terminate())?;
+    let mut int_ = signal(SignalKind::interrupt())?;
+    let mut tick = tokio::time::interval(std::time::Duration::from_secs(60));
+    // Alinear al próximo minuto entero.
+    let now_ms = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_millis() as u64;
+    let to_next_min = 60_000 - (now_ms % 60_000);
+    tokio::time::sleep(std::time::Duration::from_millis(to_next_min)).await;
+    tick.tick().await; // descartar primer tick post-alignment
+
+    info!("scheduler activo (cron 5-field UTC)");
+    loop {
+        tokio::select! {
+            _ = tick.tick() => {
+                let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() as i64;
+                let bits = time_bits_utc(now);
+                for (cfg, cron) in &parsed {
+                    if cron.matches(&bits) {
+                        fire(cfg).await;
+                    }
+                }
+            }
+            _ = term.recv() => { info!("SIGTERM"); return Ok(()); }
+            _ = int_.recv() => { info!("SIGINT"); return Ok(()); }
+        }
+    }
+}
+
+async fn fire(cfg: &TimerConfig) {
+    info!(name = %cfg.name, "TIMER FIRE");
+    if cfg.card.is_none() {
+        return;
+    }
+    // En el futuro: forwardear via bus a un proveedor que haga SpawnRequest.
+    // Por ahora log estructurado.
+    info!(name = %cfg.name, "card spawn requested (no-op por ahora)");
+}
+
+fn load_timers() -> Vec<TimerConfig> {
+    let path = std::env::var("ENTE_TIMERS_FILE")
+        .unwrap_or_else(|_| "/etc/ente/timers.json".into());
+    match std::fs::read_to_string(&path) {
+        Ok(content) => serde_json::from_str(&content).unwrap_or_else(|e| {
+            warn!(?e, path, "timers.json inválido — sin timers");
+            vec![]
+        }),
+        Err(_) => {
+            info!(path, "timers.json ausente — scheduler inactivo");
+            vec![]
+        }
+    }
+}
+
+async fn announce_to_fractal() {
+    if let Ok(mut client) = BusClient::from_env().await {
+        let req = BusRequest::Announce {
+            capabilities: vec![Capability::Endpoint {
+                interface: ente_card::InterfaceId([0xa8; 16]),
+                version: 1,
+            }],
+        };
+        match client.call(req).await {
+            Ok(BusResponse::Ok) => info!("Announce → bus interno OK"),
+            Ok(other) => warn!(?other, "Announce respuesta inesperada"),
+            Err(e) => warn!(?e, "Announce falló"),
+        }
+    }
+}
+
+fn init_tracing() {
+    let filter = EnvFilter::try_from_default_env()
+        .unwrap_or_else(|_| EnvFilter::new("ente_timer_compat=info"));
+    tracing_subscriber::fmt().with_env_filter(filter).with_target(true).init();
+}
diff --git a/crates/ente-zero/src/seed.rs b/crates/ente-zero/src/seed.rs
index 5d23bc2..2b9c736 100644
--- a/crates/ente-zero/src/seed.rs
+++ b/crates/ente-zero/src/seed.rs
@@ -157,6 +157,9 @@ fn synthesize_dev_seed() -> EntityCard {
         ("compat-polkit",    "target/debug/ente-polkit-compat"),
         ("compat-machined",  "target/debug/ente-machined-compat"),
         ("policy-provider",  "target/debug/ente-policy-provider"),
+        ("compat-systemd1",  "target/debug/ente-systemd1-compat"),
+        ("compat-notify",    "target/debug/ente-notify-compat"),
+        ("compat-timer",     "target/debug/ente-timer-compat"),
     ] {
         if let Some(card) = optional_native_card(
             label, bin,