opencrabs 0.5.11

The autonomous, self-improving AI agent. Single Rust binary. Every channel. Recommended: the 40MB prebuilt binary for macOS, Linux and Windows: https://github.com/opencrabs/opencrabs/releases
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
//! Pre-migration image snapshot (#1779).
//!
//! The rpi5 incident: `/evolve` restarted the daemon, `run_migrations` wrote
//! `ALTER TABLE` against an already-damaged image, and the cron rows died
//! because the file being migrated was the only copy of itself. These tests pin
//! the two halves of the fix: the snapshot happens BEFORE any write, and a
//! snapshot that cannot be taken STOPS the migration.

use crate::config::profile::with_home_override_async;
use crate::db::Database;
use crate::db::database::MIGRATION_SQL;
use crate::db::migration_heal::{heals_would_write, thread_id_migration_index};
use crate::db::migration_snapshot::{
    LATEST, PREFIX, RETENTION, RETENTION_BYTES, collect_dated, prune_dated, rotate,
    snapshot_before_migrations,
};
use rusqlite::{Connection, OpenFlags};
use std::path::{Path, PathBuf};

/// A file-backed database carrying one table and no migrations applied.
///
/// The canary table is what makes it "non-empty": the snapshot must contain it,
/// and must NOT contain anything a migration would have created.
fn seeded_db(dir: &Path) -> PathBuf {
    let path = dir.join("opencrabs.db");
    let conn = Connection::open(&path).unwrap();
    conn.execute("CREATE TABLE canary(x TEXT)", []).unwrap();
    conn.execute("INSERT INTO canary VALUES ('nicole')", [])
        .unwrap();
    drop(conn);
    path
}

fn tables_in(path: &Path) -> Vec<String> {
    // READ_WRITE rather than READ_ONLY on purpose: the live database under test
    // is in WAL mode by the time these helpers run (the pool's post_create hook
    // applies the pragma), and a WAL database cannot be opened read-only without
    // its -shm sidecar. No CREATE flag, so a missing file is still a hard failure
    // instead of a silently empty result.
    let conn = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_WRITE).unwrap();
    let mut stmt = conn
        .prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
        .unwrap();
    let rows: Vec<String> = stmt
        .query_map([], |r| r.get::<_, String>(0))
        .unwrap()
        .map(|r| r.unwrap())
        .collect();
    rows
}

fn dated_snapshots(dir: &Path) -> Vec<PathBuf> {
    let mut v: Vec<PathBuf> = std::fs::read_dir(dir)
        .unwrap()
        .filter_map(|e| e.ok())
        .map(|e| e.path())
        .filter(|p| {
            p.file_name()
                .and_then(|n| n.to_str())
                .map(|n| n.starts_with(PREFIX) && n != LATEST)
                .unwrap_or(false)
        })
        .collect();
    v.sort();
    v
}

// ---------------------------------------------------------------- outcome

#[tokio::test]
async fn non_empty_db_is_snapshotted_before_migrations_run() {
    let tmp = tempfile::tempdir().unwrap();
    let home = tmp.path().join(".opencrabs");
    std::fs::create_dir_all(&home).unwrap();
    let db_path = seeded_db(&home);

    let db = with_home_override_async(home.clone(), async {
        let db = Database::connect(&db_path).await.unwrap();
        db.run_migrations().await.unwrap();
        db
    })
    .await;
    let _ = db;

    let backups = home.join("backups");
    let snaps = dated_snapshots(&backups);
    assert_eq!(
        snaps.len(),
        1,
        "exactly one pre-migration snapshot expected"
    );

    // The ordering claim, measured rather than asserted from a log line: the
    // snapshot holds the canary and none of the migration's tables, so it was
    // taken before `to_latest` ran.
    let names = tables_in(&snaps[0]);
    assert!(
        names.contains(&"canary".to_string()),
        "snapshot must carry the pre-migration data, got {names:?}"
    );
    assert!(
        !names.contains(&"sessions".to_string()),
        "snapshot was taken AFTER migrations ran, not before: got {names:?}"
    );

    // The alias the refusal message promises must exist and match the newest.
    let latest = backups.join(LATEST);
    assert!(latest.exists(), "-latest alias must always name a file");
    assert_eq!(
        std::fs::read(&latest).unwrap().len(),
        std::fs::read(&snaps[0]).unwrap().len(),
        "-latest must name the same bytes as the newest snapshot"
    );
    #[cfg(unix)]
    {
        use std::os::unix::fs::MetadataExt;
        let alias = std::fs::metadata(&latest).unwrap();
        let dated_meta = std::fs::metadata(&snaps[0]).unwrap();
        assert_eq!(
            (alias.dev(), alias.ino()),
            (dated_meta.dev(), dated_meta.ino()),
            "-latest must be a hard link to the dated snapshot, not a second copy"
        );
    }
}

#[tokio::test]
async fn second_startup_does_not_write_another_snapshot() {
    let tmp = tempfile::tempdir().unwrap();
    let home = tmp.path().join(".opencrabs");
    std::fs::create_dir_all(&home).unwrap();
    let db_path = seeded_db(&home);

    with_home_override_async(home.clone(), async {
        let db = Database::connect(&db_path).await.unwrap();
        db.run_migrations().await.unwrap();
        db.run_migrations().await.unwrap();
    })
    .await;

    let snaps = dated_snapshots(&home.join("backups"));
    assert_eq!(
        snaps.len(),
        1,
        "a database already at the latest migration must not VACUUM INTO again: {snaps:?}"
    );
}

#[tokio::test]
async fn fresh_empty_db_skips_the_snapshot_silently() {
    let tmp = tempfile::tempdir().unwrap();
    let home = tmp.path().join(".opencrabs");
    std::fs::create_dir_all(&home).unwrap();
    let db_path = home.join("opencrabs.db");
    Connection::open(&db_path).unwrap(); // no tables at all

    with_home_override_async(home.clone(), async {
        let db = Database::connect(&db_path).await.unwrap();
        db.run_migrations().await.unwrap();
    })
    .await;

    let backups = home.join("backups");
    let snaps = if backups.exists() {
        dated_snapshots(&backups)
    } else {
        vec![]
    };
    assert!(
        snaps.is_empty(),
        "a brand-new database has nothing to protect: got {snaps:?}"
    );
}

/// The whole test suite calls `run_migrations()` on in-memory databases, and
/// `Connection::path()` strips a URI's query part, so `?mode=memory` is
/// invisible to it. Without the `PRAGMA database_list` check every `cargo test`
/// run would write 70 MB snapshots into the developer's real home.
#[tokio::test]
async fn in_memory_db_never_snapshots() {
    let tmp = tempfile::tempdir().unwrap();
    let home = tmp.path().join(".opencrabs");
    std::fs::create_dir_all(&home).unwrap();

    with_home_override_async(home.clone(), async {
        let db = Database::connect_in_memory().await.unwrap();
        db.run_migrations().await.unwrap();
    })
    .await;

    assert!(
        !home.join("backups").exists(),
        "in-memory database must not create a backups dir"
    );
}

// ---------------------------------------------------------------- retention

#[tokio::test]
async fn rotation_keeps_seven_dated_copies_and_the_alias() {
    let tmp = tempfile::tempdir().unwrap();
    let db_path = seeded_db(tmp.path());
    let backups = tmp.path().join("backups");
    let conn = Connection::open(&db_path).unwrap();

    // Nine startups. Same-second collisions are expected in a crash-restart
    // loop, so the names must stay distinct rather than fail.
    for _ in 0..9 {
        snapshot_before_migrations(&conn, &backups)
            .unwrap()
            .expect("non-empty db must snapshot");
    }

    let snaps = dated_snapshots(&backups);
    assert_eq!(
        snaps.len(),
        RETENTION,
        "retention is the directed 7-day window, got {}",
        snaps.len()
    );
    assert!(
        backups.join(LATEST).exists(),
        "rotation must never prune the alias"
    );

    // Oldest first, so the survivors are the newest RETENTION.
    let removed = rotate(&backups).unwrap();
    assert_eq!(removed, 0, "already at the cap, nothing to prune");
    assert_eq!(dated_snapshots(&backups).len(), RETENTION);
}

// ------------------------------------------------------------ refusal gate

#[tokio::test]
async fn migrations_are_refused_when_the_snapshot_cannot_be_taken() {
    let tmp = tempfile::tempdir().unwrap();
    let home = tmp.path().join(".opencrabs");
    std::fs::create_dir_all(&home).unwrap();
    let db_path = seeded_db(&home);

    // Sabotage the destination: a regular FILE where the backups directory has
    // to be, so create_dir_all fails and VACUUM INTO never gets a chance.
    std::fs::write(home.join("backups"), b"not a directory").unwrap();

    let (before, err) = with_home_override_async(home.clone(), async {
        let db = Database::connect(&db_path).await.unwrap();
        // Baseline AFTER the pool's first real connection, not before it.
        // connect() is lazy: the first get() runs post_create, whose
        // journal_mode=WAL rewrites the image header (bytes 18-19 go 1,1 -> 2,2)
        // and bumps the change counter. That is SQLite converting the file to
        // WAL, it happens on every single open, and it is not a migration.
        // Measuring from the pre-open bytes would fail on the pragma and prove
        // nothing about the refusal.
        let _warm = db.pool.get().await.unwrap();
        let before = std::fs::read(&db_path).unwrap();
        let err = db.run_migrations().await.unwrap_err();
        (before, err)
    })
    .await;

    let msg = format!("{err:#}");
    assert!(
        msg.contains("Refusing to run database migrations"),
        "message must say it refused, got: {msg}"
    );
    assert!(
        !msg.contains("Failed to run database migrations"),
        "the misleading migration wording must not resurface here: {msg}"
    );
    assert!(
        msg.contains("Restore it by copying a snapshot"),
        "message must name the recovery action: {msg}"
    );
    assert!(
        msg.contains("brain files and config are untouched"),
        "message must scope the blast radius: {msg}"
    );

    // Nothing was written past the pragma. This is the zero-data-loss half of
    // the promise: a refused migration leaves the image byte-identical, so it
    // stays repairable by the rescue tooling instead of being half-migrated.
    assert_eq!(
        std::fs::read(&db_path).unwrap(),
        before,
        "refused migration must not touch the database file"
    );
    let names = tables_in(&db_path);
    assert!(
        !names.contains(&"sessions".to_string()),
        "migrations must NOT have run: got {names:?}"
    );

    // And the row that mattered is still there, still readable, still at
    // user_version 0. The byte comparison above can only ever prove "unchanged
    // since the baseline"; this proves "the data survives the refusal".
    let conn = Connection::open_with_flags(&db_path, OpenFlags::SQLITE_OPEN_READ_WRITE).unwrap();
    let version: i32 = conn
        .query_row("PRAGMA user_version", [], |r| r.get(0))
        .unwrap();
    assert_eq!(
        version, 0,
        "a refused migration must not advance user_version"
    );
    let canary: String = conn
        .query_row("SELECT x FROM canary", [], |r| r.get(0))
        .unwrap();
    assert_eq!(
        canary, "nicole",
        "the pre-existing row must survive refusal"
    );
}

// ------------------------------------------------------------ corrupt image

/// The rpi5 shape: a valid magic and header over a page-1 b-tree that no
/// longer parses.
///
/// Blanking bytes 100..200 leaves the file openable but makes
/// `PRAGMA integrity_check` fail with SQLITE_CORRUPT instead of returning a
/// report row. Verified 2026-09-28 against the sqlite3 CLI, where it was the
/// candidate that reproduced the incident's exact error string (a flipped byte
/// at 150, at 300, or a zeroed change counter all still report `ok`, so they
/// prove nothing). That distinction is the point of this corruption: a check
/// treating only non-`"ok"` *text* as damage waves this through, because there
/// is no text at all.
///
/// Returns the corrupted bytes so a test can prove nothing was written back.
fn corrupt_page_one(path: &Path) -> Vec<u8> {
    let mut bytes = std::fs::read(path).unwrap();
    bytes[100..200].fill(0);
    std::fs::write(path, &bytes).unwrap();
    bytes
}

#[tokio::test]
async fn corrupt_image_is_refused_before_any_migration_runs() {
    let tmp = tempfile::tempdir().unwrap();
    let home = tmp.path().join(".opencrabs");
    std::fs::create_dir_all(&home).unwrap();
    let db_path = seeded_db(&home);
    let corrupted = corrupt_page_one(&db_path);

    let err = with_home_override_async(home.clone(), async {
        let db = Database::connect(&db_path).await.unwrap();
        db.run_migrations().await.unwrap_err()
    })
    .await;

    let msg = format!("{err:#}");
    assert!(
        msg.contains("Refusing to run database migrations"),
        "a corrupt image must refuse, not migrate: {msg}"
    );
    assert!(
        msg.contains("the pre-migration integrity check failed"),
        "the message must name the stage that found the damage, not blur it \
         into a snapshot failure: {msg}"
    );
    assert!(
        msg.contains(&home.join("backups").display().to_string()),
        "the message must name the directory the operator restores from: {msg}"
    );
    assert!(
        msg.contains("brain files and config are untouched"),
        "the message must bound the blast radius: {msg}"
    );
    assert!(
        !msg.contains("Failed to run database migrations"),
        "the misleading wording that hid the rpi5 cause must not resurface: {msg}"
    );
    assert!(
        !msg.contains("post_create hook failed"),
        "the check must not depend on the pool: on a torn image post_create \
        cannot produce a connection at all: {msg}"
    );

    // Refusing must also mean not writing. A startup that reports corruption
    // and then rewrites the header is how the rpi5 rows were lost a second
    // time, so this is the half of the promise the operator cannot recover
    // without.
    assert_eq!(
        std::fs::read(&db_path).unwrap(),
        corrupted,
        "a refused startup must not write a single byte to the image"
    );
}

/// #459/#677: `run_migrations()` no longer runs a post-migration integrity
/// check, so nothing sets the flag on a healthy image and it must stay clear.
/// (The scan that used to set it is gone; see
/// `run_migrations_does_not_scan_on_every_open`.)
#[tokio::test]
async fn healthy_image_leaves_the_integrity_flag_clear() {
    let tmp = tempfile::tempdir().unwrap();
    let home = tmp.path().join(".opencrabs");
    std::fs::create_dir_all(&home).unwrap();
    let db_path = seeded_db(&home);

    with_home_override_async(home.clone(), async {
        let db = Database::connect(&db_path).await.unwrap();
        db.run_migrations().await.unwrap();
    })
    .await;

    assert!(
        tables_in(&db_path).contains(&"sessions".to_string()),
        "migrations must have run on a healthy image"
    );
    assert!(
        !crate::db::db_integrity_failed(),
        "a healthy image must not trip the integrity flag"
    );
}

/// #459/#677: `run_migrations()` must not scan the image on every open.
///
/// Upstream's unconditional post-migration `PRAGMA integrity_check` was
/// re-imported by the 2026-09-29 sync (`d1e4b98ea`) over #459's removal, so
/// every database open paid a full page scan -- measured 86 s on the 328 MB
/// default image and >120 s on the 2.7 GB ops image (2026-10-04), which is what
/// made `cron list` and every other cold CLI verb appear to hang.
///
/// This pins the removal by source, so the next sync fails loudly here instead
/// of silently restoring the scan.
///
/// The preflight ordering is still pinned: upstream's #1779 check must run
/// BEFORE the migration write, where it can refuse a torn image.
#[test]
fn run_migrations_does_not_scan_on_every_open() {
    const SRC: &str = include_str!("../db/database.rs");

    let preflight = SRC
        .find("integrity_preflight(")
        .expect("the pre-migration integrity check must exist in run_migrations");
    let write = SRC
        .find("migrations.to_latest(conn)")
        .expect("the migration write must exist");
    assert!(
        preflight < write,
        "the pre-migration integrity check must run BEFORE the write, found \
         preflight at {preflight} and the write at {write}"
    );

    // The scan must not come back after the write: it reads every page, so its
    // cost is O(database size) on EVERY open, and no foreground caller can act
    // on the verdict (#459, #677).
    assert!(
        !SRC.contains("Run integrity check on startup"),
        "run_migrations() must not run an unconditional integrity_check after \
         the migrations -- it scans every page on EVERY open (#459, #677)"
    );
}

#[test]
fn latest_alias_is_not_a_dated_name() {
    assert!(LATEST.starts_with(PREFIX));
    assert!(
        LATEST
            .trim_start_matches(PREFIX)
            .chars()
            .all(|c| !c.is_ascii_digit()),
        "alias must carry no digits so it is distinguishable from dated copies"
    );
}

#[test]
fn retention_is_the_directed_window() {
    assert_eq!(RETENTION, 7, "owner directive 2026-09-28: rolling 7 days");
}

#[test]
fn retention_bytes_admit_one_large_image_and_not_two() {
    let ops_image = 2_440_159_232u64;
    assert!(
        RETENTION_BYTES >= ops_image,
        "one known-good copy of a multi-gigabyte image must fit"
    );
    assert!(
        RETENTION_BYTES < ops_image * 2,
        "a second full copy must exceed the budget"
    );
}

#[test]
fn byte_budget_keeps_the_newest_by_time_not_by_version() {
    let tmp = tempfile::tempdir().unwrap();
    for name in [
        format!("{PREFIX}55-20260930-010101"),
        format!("{PREFIX}60-20260930-020202"),
        format!("{PREFIX}59-20260930-030303"),
    ] {
        let file = std::fs::File::create(tmp.path().join(&name)).unwrap();
        file.set_len(100).unwrap();
    }
    std::fs::write(tmp.path().join(LATEST), b"alias").unwrap();

    let dated = collect_dated(tmp.path());
    assert!(
        dated
            .last()
            .unwrap()
            .ends_with("opencrabs.db.pre-migration-59-20260930-030303"),
        "03:03 is newer than the version-60 file from 02:02: {dated:?}"
    );

    // 100+100 = 200 > 150, so the two older files go. The count window is 7
    // and would have kept all three.
    let removed = prune_dated(dated, RETENTION, 150);
    assert_eq!(removed, 2);
    let left = collect_dated(tmp.path());
    assert_eq!(left.len(), 1);
    assert!(left[0].ends_with("opencrabs.db.pre-migration-59-20260930-030303"));
    assert!(
        tmp.path().join(LATEST).exists(),
        "alias is not a dated copy"
    );
}

#[test]
fn thread_id_index_follows_the_migration_list() {
    let marker = "ALTER TABLE pending_requests ADD COLUMN channel_thread_id TEXT;";
    let pos = MIGRATION_SQL
        .iter()
        .position(|sql| sql.contains(marker))
        .expect("thread-id migration missing");
    assert_eq!(thread_id_migration_index(), pos as i64 + 1);
}

#[test]
fn satisfied_heals_do_not_ask_for_a_snapshot() {
    let conn = Connection::open_in_memory().unwrap();
    conn.execute_batch(
        "CREATE TABLE pending_requests (origin TEXT);
         CREATE TABLE notify_queue (id INTEGER);",
    )
    .unwrap();
    assert!(!heals_would_write(&conn, 50).unwrap());
}

#[test]
fn missing_notify_queue_would_write() {
    let conn = Connection::open_in_memory().unwrap();
    conn.execute_batch("CREATE TABLE pending_requests (origin TEXT);")
        .unwrap();
    assert!(heals_would_write(&conn, 50).unwrap());
}

#[test]
fn missing_origin_column_would_write() {
    let conn = Connection::open_in_memory().unwrap();
    conn.execute_batch(
        "CREATE TABLE pending_requests (id INTEGER);
         CREATE TABLE notify_queue (id INTEGER);",
    )
    .unwrap();
    assert!(heals_would_write(&conn, 50).unwrap());
}