Skip to main content

assay_engine/
init.rs

1//! Engine boot sequence (v0.1.2 — schema/ATTACH layout).
2//!
3//! Implements the 8-step boot sequence from plan 14:
4//!
5//! 1. Open engine storage (PG: connect; SQLite: create data_dir + open
6//!    a router connection that ATTACHes one file per module)
7//! 2. Apply engine schema migrations (creates `engine.modules`,
8//!    `engine.audit`, `engine.instances`, `engine.migrations`)
9//! 3. Read `engine.modules` — on first boot seed it from the running
10//!    build's compile-time modules; on subsequent boots just SELECT
11//!    enabled modules
12//! 4. For each enabled module: PG `CREATE SCHEMA IF NOT EXISTS <m>`
13//!    or SQLite ensure-attached, then run module migrations
14//! 5. Wire trait routing — handled by callers (engine binary builds the
15//!    `WorkflowStore` against the prepared pool)
16//! 6. Engine-level multi-node coordination:
17//!    - PG: pg_try_advisory_lock(1) for leader election (existing path)
18//!    - SQLite: engine.lock single-row exclusive (existing path)
19//!    - Insert into `engine.instances` on startup, refresh on timer,
20//!      DELETE on graceful shutdown
21//! 7. Mount HTTP routers from each enabled module (caller wires them)
22//! 8. Start scheduler, workers, etc. (caller wires them)
23//!
24//! [`EngineBoot`] returns the prepared pool(s), the engine-events bus,
25//! the instance id, and the list of enabled modules — everything callers
26//! need to compose `WorkflowStore`, `WorkflowCtx`, and the HTTP router.
27
28use std::sync::Arc;
29use std::time::Duration;
30
31use assay_domain::events::EngineEventBus;
32use tracing::info;
33
34use crate::config::{BackendConfig, EngineConfig};
35
36/// One row to seed into `engine.modules` on first boot.
37/// `default_enabled = false` means operators must flip it to TRUE
38/// before its migrations run — used for opt-in modules like auth so
39/// existing v0.1.2 deployments don't get unexpected schema changes.
40#[derive(Debug, Clone)]
41pub struct BuiltinModule {
42    pub name: &'static str,
43    pub version: &'static str,
44    pub default_enabled: bool,
45}
46
47/// Built-in modules implied by the running build's compile-time features.
48///
49/// Workflow is always-on (the engine is currently the workflow runtime).
50/// Auth — when compiled in via the `auth` Cargo feature — seeds disabled
51/// so operators of existing v0.1.2 deployments don't get unexpected
52/// auth migrations on upgrade. Local dev flips this via
53/// `EngineConfig.auto_enable_modules = ["auth"]`.
54pub fn builtin_modules() -> Vec<BuiltinModule> {
55    #[cfg_attr(not(feature = "vault"), allow(unused_mut))]
56    let mut mods = vec![
57        BuiltinModule {
58            name: "workflow",
59            version: env!("CARGO_PKG_VERSION"),
60            default_enabled: true,
61        },
62        // engine itself authenticates every admin + workflow request via
63        // the auth module; running with auth disabled isn't supported.
64        BuiltinModule {
65            name: "auth",
66            version: env!("CARGO_PKG_VERSION"),
67            default_enabled: true,
68        },
69    ];
70    // Vault module (plan 17 / v0.3.0). Default-enabled when compiled in —
71    // this is the marquee module of v0.3.0 and the engine binary's vault
72    // wiring panics if the module is on without a backing VaultCtx.
73    #[cfg(feature = "vault")]
74    mods.push(BuiltinModule {
75        name: "vault",
76        version: env!("CARGO_PKG_VERSION"),
77        default_enabled: true,
78    });
79    mods
80}
81
82/// Heartbeat interval for the engine.instances row. Tightened in
83/// v0.3.0 (plan 17 §S9) so secondary engine pods can detect a failed
84/// primary within [`INSTANCE_STALE_SECS`] of the actual failure.
85///
86/// 3-second heartbeat × 10-second stale-cutoff = primary loss is
87/// observed by every other instance within ~10s (worst case: a
88/// heartbeat just succeeded, then the primary dies; the row stays
89/// "fresh" for the remainder of the 10-second window).
90///
91/// HA tradeoffs:
92/// - Lower heartbeat → faster failover detection, more PG writes per
93///   second per pod (~1 row/s/pod is negligible at any realistic
94///   fleet size).
95/// - Higher stale cutoff → reduces false-positive failovers from
96///   transient network blips, but slows real-failure detection.
97///
98/// 3s × 10s is plan §S9's locked target.
99const INSTANCE_HEARTBEAT_SECS: u64 = 3;
100// Used by the PG cleanup task that prunes dead `engine.instances` rows.
101// SQLite path is single-instance and never accumulates stale rows.
102#[cfg(feature = "backend-postgres")]
103const INSTANCE_STALE_SECS: f64 = 10.0;
104
105/// Result of the engine boot sequence — the parts each backend wired up.
106/// The engine binary uses these to compose its `WorkflowStore` /
107/// `WorkflowCtx` / HTTP router.
108pub enum EngineBoot {
109    #[cfg(feature = "backend-postgres")]
110    Postgres(PgBoot),
111    #[cfg(feature = "backend-sqlite")]
112    Sqlite(SqliteBoot),
113}
114
115#[cfg(feature = "backend-postgres")]
116pub struct PgBoot {
117    pub pool: sqlx::PgPool,
118    pub bus: Arc<dyn EngineEventBus>,
119    pub instance_id: uuid::Uuid,
120    pub modules: Vec<String>,
121}
122
123#[cfg(feature = "backend-sqlite")]
124pub struct SqliteBoot {
125    pub pool: sqlx::SqlitePool,
126    pub bus: Arc<dyn EngineEventBus>,
127    pub instance_id: uuid::Uuid,
128    pub modules: Vec<String>,
129}
130
131impl EngineBoot {
132    /// Run the boot sequence end-to-end against the configured backend.
133    pub async fn run(cfg: &EngineConfig) -> anyhow::Result<Self> {
134        match cfg.backend.clone() {
135            #[cfg(feature = "backend-postgres")]
136            BackendConfig::Postgres { url } => {
137                let boot = pg_boot(&url, &cfg.auto_enable_modules).await?;
138                Ok(EngineBoot::Postgres(boot))
139            }
140            #[cfg(feature = "backend-sqlite")]
141            BackendConfig::Sqlite { .. } => {
142                let data_dir = cfg
143                    .backend
144                    .sqlite_data_dir()
145                    .expect("sqlite backend yields data_dir");
146                let boot = sqlite_boot(&data_dir, &cfg.auto_enable_modules).await?;
147                Ok(EngineBoot::Sqlite(boot))
148            }
149            #[allow(unreachable_patterns)]
150            _ => anyhow::bail!("backend not enabled at compile time"),
151        }
152    }
153
154    pub fn modules(&self) -> &[String] {
155        match self {
156            #[cfg(feature = "backend-postgres")]
157            EngineBoot::Postgres(b) => &b.modules,
158            #[cfg(feature = "backend-sqlite")]
159            EngineBoot::Sqlite(b) => &b.modules,
160        }
161    }
162
163    pub fn instance_id(&self) -> uuid::Uuid {
164        match self {
165            #[cfg(feature = "backend-postgres")]
166            EngineBoot::Postgres(b) => b.instance_id,
167            #[cfg(feature = "backend-sqlite")]
168            EngineBoot::Sqlite(b) => b.instance_id,
169        }
170    }
171}
172
173#[cfg(feature = "backend-postgres")]
174async fn pg_boot(url: &str, auto_enable: &[String]) -> anyhow::Result<PgBoot> {
175    use assay_domain::engine::PgEngineSchema;
176    use assay_domain::events::PgEngineEventBus;
177    use sqlx::PgPool;
178
179    info!(target: "assay-engine", "boot: connecting to postgres");
180    let pool = PgPool::connect(url)
181        .await
182        .map_err(|e| anyhow::anyhow!("connect postgres: {e}"))?;
183
184    let schema = PgEngineSchema::new(pool.clone());
185    schema
186        .migrate()
187        .await
188        .map_err(|e| anyhow::anyhow!("engine schema migrate (pg): {e}"))?;
189    record_engine_migration_pg(&pool, "engine", 1).await?;
190
191    let modules = read_or_seed_modules_pg(&schema, auto_enable).await?;
192
193    // Per-module schema setup. The workflow module's actual DDL still
194    // runs inside `PostgresStore::migrate` when the engine binary builds
195    // the store — Phase 2 already moved those tables into the `workflow`
196    // schema. We just ensure the schema container exists here so a fresh
197    // boot doesn't fail before the store's CREATE TABLE runs.
198    let mut tx = pool
199        .begin()
200        .await
201        .map_err(|e| anyhow::anyhow!("begin module schema tx: {e}"))?;
202    assay_domain::engine::acquire_schema_lock(&mut tx)
203        .await
204        .map_err(|e| anyhow::anyhow!("acquire schema migration advisory lock: {e}"))?;
205    for name in &modules {
206        let create = format!("CREATE SCHEMA IF NOT EXISTS {name}");
207        sqlx::query(&create)
208            .execute(&mut *tx)
209            .await
210            .map_err(|e| anyhow::anyhow!("create schema {name}: {e}"))?;
211        sqlx::query(
212            "INSERT INTO engine.migrations (module, version)
213             VALUES ($1, $2) ON CONFLICT DO NOTHING",
214        )
215        .bind(name)
216        .bind(1)
217        .execute(&mut *tx)
218        .await
219        .map_err(|e| anyhow::anyhow!("record engine.migrations row {name}/1: {e}"))?;
220    }
221    tx.commit()
222        .await
223        .map_err(|e| anyhow::anyhow!("commit module schema tx: {e}"))?;
224
225    // Auth schema migration — always runs (auth is mandatory per
226    // boot) and smoke-touches the OIDC provider tables so missing DDL or
227    // permission issues surface here rather than at first request.
228    if modules.iter().any(|m| m == "auth") {
229        assay_auth::schema::migrate_postgres(&pool)
230            .await
231            .map_err(|e| anyhow::anyhow!("auth schema migrate (pg): {e}"))?;
232        let _ = assay_auth::biscuit::load_or_init_postgres(&pool)
233            .await
234            .map_err(|e| anyhow::anyhow!("biscuit root key bootstrap (pg): {e}"))?;
235        sqlx::query("SELECT COUNT(*) FROM auth.oidc_clients")
236            .fetch_one(&pool)
237            .await
238            .map_err(|e| anyhow::anyhow!("oidc provider tables (pg): {e}"))?;
239    }
240
241    // Vault schema migration (plan 17 / v0.3.0). Smoke-touches one of
242    // the locked tables so missing DDL or permission issues surface here.
243    #[cfg(feature = "vault")]
244    if modules.iter().any(|m| m == "vault") {
245        assay_vault::schema::migrate_postgres(&pool)
246            .await
247            .map_err(|e| anyhow::anyhow!("vault schema migrate (pg): {e}"))?;
248        sqlx::query("SELECT COUNT(*) FROM vault.kv_meta")
249            .fetch_one(&pool)
250            .await
251            .map_err(|e| anyhow::anyhow!("vault tables (pg): {e}"))?;
252    }
253
254    let bus: Arc<dyn EngineEventBus> = Arc::new(
255        PgEngineEventBus::new(pool.clone(), url)
256            .await
257            .map_err(|e| anyhow::anyhow!("engine-events bus (pg): {e}"))?,
258    );
259
260    let instance_id = schema
261        .register_instance(&modules, Some(env!("CARGO_PKG_VERSION")))
262        .await
263        .map_err(|e| anyhow::anyhow!("register engine.instances row: {e}"))?;
264    spawn_pg_instance_lifecycle(pool.clone(), instance_id);
265
266    info!(target: "assay-engine", instance = %instance_id, modules = ?modules, "boot complete (pg)");
267    Ok(PgBoot {
268        pool,
269        bus,
270        instance_id,
271        modules,
272    })
273}
274
275#[cfg(feature = "backend-postgres")]
276async fn read_or_seed_modules_pg(
277    schema: &assay_domain::engine::PgEngineSchema,
278    auto_enable: &[String],
279) -> anyhow::Result<Vec<String>> {
280    let existing = schema
281        .list_modules()
282        .await
283        .map_err(|e| anyhow::anyhow!("list engine.modules (pg): {e}"))?;
284    let known: std::collections::HashSet<String> =
285        existing.iter().map(|m| m.name.clone()).collect();
286
287    // Seed any compile-time module that isn't already in engine.modules.
288    // Each module's `default_enabled` is honoured unless the operator
289    // explicitly listed it in `auto_enable_modules` — that override
290    // exists so local-dev configs can flip auth on without an extra
291    // setup step.
292    for module in builtin_modules() {
293        if known.contains(module.name) {
294            continue;
295        }
296        let enabled = module.default_enabled || auto_enable.iter().any(|n| n == module.name);
297        schema
298            .upsert_module(module.name, Some(module.version), enabled)
299            .await
300            .map_err(|e| anyhow::anyhow!("seed engine.modules row {}: {e}", module.name))?;
301    }
302
303    let final_list = schema
304        .list_modules()
305        .await
306        .map_err(|e| anyhow::anyhow!("re-list engine.modules (pg): {e}"))?;
307    Ok(final_list
308        .into_iter()
309        .filter(|m| m.enabled)
310        .map(|m| m.name)
311        .collect())
312}
313
314#[cfg(feature = "backend-postgres")]
315async fn record_engine_migration_pg(
316    pool: &sqlx::PgPool,
317    module: &str,
318    version: i32,
319) -> anyhow::Result<()> {
320    sqlx::query(
321        "INSERT INTO engine.migrations (module, version)
322         VALUES ($1, $2) ON CONFLICT DO NOTHING",
323    )
324    .bind(module)
325    .bind(version)
326    .execute(pool)
327    .await
328    .map_err(|e| anyhow::anyhow!("record engine.migrations row {module}/{version}: {e}"))?;
329    Ok(())
330}
331
332#[cfg(feature = "backend-postgres")]
333fn spawn_pg_instance_lifecycle(pool: sqlx::PgPool, id: uuid::Uuid) {
334    use assay_domain::engine::PgEngineSchema;
335    let schema = PgEngineSchema::new(pool.clone());
336    tokio::spawn(async move {
337        let mut tick = tokio::time::interval(Duration::from_secs(INSTANCE_HEARTBEAT_SECS));
338        loop {
339            tick.tick().await;
340            if let Err(e) = schema.heartbeat_instance(id).await {
341                tracing::warn!(?e, %id, "engine.instances heartbeat failed");
342            }
343            // Best-effort stale cleanup. Idempotent — multiple instances
344            // racing the same DELETE is fine.
345            let cutoff_sql = format!(
346                "DELETE FROM engine.instances
347                 WHERE last_heartbeat < EXTRACT(EPOCH FROM NOW()) - {INSTANCE_STALE_SECS}"
348            );
349            if let Err(e) = sqlx::query(&cutoff_sql).execute(&pool).await {
350                tracing::debug!(?e, "engine.instances stale cleanup failed");
351            }
352        }
353    });
354}
355
356#[cfg(feature = "backend-sqlite")]
357async fn sqlite_boot(data_dir: &str, auto_enable: &[String]) -> anyhow::Result<SqliteBoot> {
358    use assay_domain::engine::SqliteEngineSchema;
359    use assay_domain::events::SqliteEngineEventBus;
360
361    let pool = sqlite_pool(data_dir).await?;
362
363    let schema = SqliteEngineSchema::new(pool.clone());
364    schema
365        .migrate()
366        .await
367        .map_err(|e| anyhow::anyhow!("engine schema migrate (sqlite): {e}"))?;
368    record_engine_migration_sqlite(&pool, "engine", 1).await?;
369
370    let modules = read_or_seed_modules_sqlite(&schema, auto_enable).await?;
371    for name in &modules {
372        record_engine_migration_sqlite(&pool, name, 1).await?;
373    }
374
375    // Auth schema migration — always runs (auth is mandatory per
376    if modules.iter().any(|m| m == "auth") {
377        assay_auth::schema::migrate_sqlite(&pool)
378            .await
379            .map_err(|e| anyhow::anyhow!("auth schema migrate (sqlite): {e}"))?;
380        let _ = assay_auth::biscuit::load_or_init_sqlite(&pool)
381            .await
382            .map_err(|e| anyhow::anyhow!("biscuit root key bootstrap (sqlite): {e}"))?;
383        sqlx::query("SELECT COUNT(*) FROM auth.oidc_clients")
384            .fetch_one(&pool)
385            .await
386            .map_err(|e| anyhow::anyhow!("oidc provider tables (sqlite): {e}"))?;
387    }
388
389    // Vault schema migration (plan 17 / v0.3.0).
390    #[cfg(feature = "vault")]
391    if modules.iter().any(|m| m == "vault") {
392        assay_vault::schema::migrate_sqlite(&pool)
393            .await
394            .map_err(|e| anyhow::anyhow!("vault schema migrate (sqlite): {e}"))?;
395        sqlx::query("SELECT COUNT(*) FROM vault.kv_meta")
396            .fetch_one(&pool)
397            .await
398            .map_err(|e| anyhow::anyhow!("vault tables (sqlite): {e}"))?;
399    }
400
401    let bus: Arc<dyn EngineEventBus> = Arc::new(
402        SqliteEngineEventBus::new(pool.clone())
403            .await
404            .map_err(|e| anyhow::anyhow!("engine-events bus (sqlite): {e}"))?,
405    );
406
407    let instance_id = schema
408        .register_instance(&modules, Some(env!("CARGO_PKG_VERSION")))
409        .await
410        .map_err(|e| anyhow::anyhow!("register engine.instances row: {e}"))?;
411    spawn_sqlite_instance_lifecycle(pool.clone(), instance_id);
412
413    info!(target: "assay-engine", instance = %instance_id, modules = ?modules, "boot complete (sqlite)");
414    Ok(SqliteBoot {
415        pool,
416        bus,
417        instance_id,
418        modules,
419    })
420}
421
422/// Open the engine's SQLite store: an in-memory router connection with
423/// one ATTACHed database per module, so engine-qualified queries read
424/// the same on both backends. Shared with the store-migration command.
425#[cfg(feature = "backend-sqlite")]
426pub(crate) async fn sqlite_pool(data_dir: &str) -> anyhow::Result<sqlx::SqlitePool> {
427    use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
428    use std::str::FromStr;
429
430    let in_memory = data_dir == ":memory:";
431    if !in_memory {
432        std::fs::create_dir_all(data_dir)
433            .map_err(|e| anyhow::anyhow!("create data_dir {data_dir}: {e}"))?;
434    }
435
436    let opts = SqliteConnectOptions::from_str("sqlite::memory:")?.create_if_missing(true);
437    let attachments: Vec<(&str, String)> = SQLITE_MODULE_DBS
438        .iter()
439        .map(|m| (*m, sqlite_attach_uri(data_dir, m, in_memory)))
440        .collect();
441
442    info!(
443        target: "assay-engine",
444        data_dir = %data_dir,
445        modules = ?SQLITE_MODULE_DBS,
446        "opening sqlite engine pool"
447    );
448
449    SqlitePoolOptions::new()
450        .max_connections(1)
451        .after_connect(move |conn, _meta| {
452            let attachments = attachments.clone();
453            Box::pin(async move {
454                use sqlx::Executor;
455                for (name, uri) in attachments {
456                    conn.execute(format!("ATTACH DATABASE '{uri}' AS {name}").as_str())
457                        .await?;
458                }
459                Ok(())
460            })
461        })
462        .connect_with(opts)
463        .await
464        .map_err(|e| anyhow::anyhow!("connect sqlite: {e}"))
465}
466
467/// Module databases ATTACHed into the SQLite router, in ATTACH order.
468#[cfg(feature = "backend-sqlite")]
469pub(crate) const SQLITE_MODULE_DBS: &[&str] = &[
470    "engine",
471    "workflow",
472    "auth",
473    #[cfg(feature = "vault")]
474    "vault",
475];
476
477#[cfg(feature = "backend-sqlite")]
478fn sqlite_attach_uri(data_dir: &str, module: &str, in_memory: bool) -> String {
479    if in_memory {
480        // Shared-cache memdb so every connection in the pool sees the
481        // same in-memory tables, and so reopening the pool after process
482        // restart picks up the fresh DB. Per-process suffix avoids
483        // collisions when multiple engines run in the same test binary.
484        use std::sync::atomic::{AtomicU64, Ordering};
485        static SEQ: AtomicU64 = AtomicU64::new(0);
486        let suffix = format!(
487            "{}_{}",
488            std::process::id(),
489            SEQ.fetch_add(1, Ordering::Relaxed)
490        );
491        format!("file:assay_{module}_{suffix}?mode=memory&cache=shared")
492    } else {
493        format!("file:{data_dir}/{module}.db?mode=rwc")
494    }
495}
496
497#[cfg(feature = "backend-sqlite")]
498async fn read_or_seed_modules_sqlite(
499    schema: &assay_domain::engine::SqliteEngineSchema,
500    auto_enable: &[String],
501) -> anyhow::Result<Vec<String>> {
502    let existing = schema
503        .list_modules()
504        .await
505        .map_err(|e| anyhow::anyhow!("list engine.modules (sqlite): {e}"))?;
506    let known: std::collections::HashSet<String> =
507        existing.iter().map(|m| m.name.clone()).collect();
508
509    // Same per-module insert pattern as the PG path: skip rows that
510    // already exist, honour `default_enabled` unless the operator
511    // explicitly auto-enabled the module.
512    for module in builtin_modules() {
513        if known.contains(module.name) {
514            continue;
515        }
516        let enabled = module.default_enabled || auto_enable.iter().any(|n| n == module.name);
517        schema
518            .upsert_module(module.name, Some(module.version), enabled)
519            .await
520            .map_err(|e| anyhow::anyhow!("seed engine.modules row {}: {e}", module.name))?;
521    }
522
523    let final_list = schema
524        .list_modules()
525        .await
526        .map_err(|e| anyhow::anyhow!("re-list engine.modules (sqlite): {e}"))?;
527    Ok(final_list
528        .into_iter()
529        .filter(|m| m.enabled)
530        .map(|m| m.name)
531        .collect())
532}
533
534#[cfg(feature = "backend-sqlite")]
535async fn record_engine_migration_sqlite(
536    pool: &sqlx::SqlitePool,
537    module: &str,
538    version: i32,
539) -> anyhow::Result<()> {
540    sqlx::query(
541        "INSERT OR IGNORE INTO engine.migrations (module, version)
542         VALUES (?, ?)",
543    )
544    .bind(module)
545    .bind(version)
546    .execute(pool)
547    .await
548    .map_err(|e| anyhow::anyhow!("record engine.migrations row {module}/{version}: {e}"))?;
549    Ok(())
550}
551
552#[cfg(feature = "backend-sqlite")]
553fn spawn_sqlite_instance_lifecycle(pool: sqlx::SqlitePool, id: uuid::Uuid) {
554    use assay_domain::engine::SqliteEngineSchema;
555    let schema = SqliteEngineSchema::new(pool);
556    tokio::spawn(async move {
557        let mut tick = tokio::time::interval(Duration::from_secs(INSTANCE_HEARTBEAT_SECS));
558        loop {
559            tick.tick().await;
560            if let Err(e) = schema.heartbeat_instance(id).await {
561                tracing::warn!(?e, %id, "engine.instances heartbeat failed");
562            }
563        }
564    });
565}
566
567#[cfg(all(test, feature = "backend-sqlite"))]
568mod tests {
569    use super::*;
570
571    /// Plan-15 slice 3: auth is default-enabled. The `auto_enable_modules`
572    /// argument is a no-op for auth (kept as a setting for forward-compat
573    /// with future opt-in modules) — auth always runs its migration on
574    /// first boot now.
575    #[tokio::test(flavor = "multi_thread")]
576    async fn sqlite_boot_default_runs_auth_migration() {
577        let boot = sqlite_boot(":memory:", &[]).await.expect("boot");
578        assert!(
579            boot.modules.iter().any(|m| m == "auth"),
580            "auth must be in active modules by default; got {:?}",
581            boot.modules
582        );
583        // Auth migration recorded.
584        let auth_row: Option<(String,)> =
585            sqlx::query_as("SELECT module FROM engine.migrations WHERE module = 'auth'")
586                .fetch_optional(&boot.pool)
587                .await
588                .expect("query engine.migrations");
589        assert!(
590            auth_row.is_some(),
591            "engine.migrations should have an auth row after auto-enabled boot"
592        );
593        // auth.users table should exist (proves migrate_sqlite ran against
594        // the ATTACHed auth db).
595        let user_count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM auth.users")
596            .fetch_one(&boot.pool)
597            .await
598            .expect("count auth.users");
599        assert_eq!(user_count.0, 0);
600    }
601}