cubesys/cubecli: session note/message-save-path (notes) — WordFlags-tagged CZYX note log (doc_type=note, per-session owner + created_at day), store_note/list/search/show + note_command dispatcher, durable CubeStore+WAL+checkpoint; cube note add|list|search|show. New cubesys::notes module w/ 3 tests; workspace check green.
This commit is contained in:
+53
-7
@@ -62,9 +62,33 @@ fn is_command_word(w: &str) -> bool {
|
||||
| "rollback"
|
||||
| "stats"
|
||||
| "audit"
|
||||
| "note"
|
||||
| "notes"
|
||||
)
|
||||
}
|
||||
|
||||
/// Open (or create) the durable session-note store. Notes persist across CLI
|
||||
/// invocations and sessions — the CUBE "message save path" — via the WAL +
|
||||
/// checkpoint store. The directory is `$CUBE_NOTES_DIR` (`~/.cubelinux-notes`).
|
||||
fn open_notes_store() -> cubesys::store::ConcurrentStore {
|
||||
let dir = std::env::var("CUBE_NOTES_DIR").unwrap_or_else(|_| {
|
||||
format!(
|
||||
"{}/.cubelinux-notes",
|
||||
std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string())
|
||||
)
|
||||
});
|
||||
cubesys::store::ConcurrentStore::open(
|
||||
&format!("{dir}/notes.store"),
|
||||
&format!("{dir}/notes.wal"),
|
||||
&format!("{dir}/recovery.log"),
|
||||
cubesys::store::DurabilityConfig::default(),
|
||||
)
|
||||
.unwrap_or_else(|e| {
|
||||
eprintln!("error: cannot open notes store {dir}: {e}");
|
||||
std::process::exit(1);
|
||||
})
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
match args.get(1).map(|s| s.as_str()) {
|
||||
@@ -75,12 +99,29 @@ fn main() {
|
||||
// the coordinate-addressed API a real CLI surface the OS can call.
|
||||
Some(word) if is_command_word(word) => {
|
||||
let line = args[1..].join(" ");
|
||||
let mut session = Session::new();
|
||||
match session.exec(&line) {
|
||||
Ok(out) => println!("{out}"),
|
||||
Err(e) => {
|
||||
eprintln!("error: {e}");
|
||||
std::process::exit(1);
|
||||
// Session notes live in their own DURABLE store so a note written
|
||||
// in one session is reviewable in the next (the message save path).
|
||||
if word == "note" || word == "notes" {
|
||||
let store = open_notes_store();
|
||||
let res = cubesys::notes::note_command(&store, &line);
|
||||
// Force a durable flush/checkpoint before exiting so the note is
|
||||
// persisted even though this process (the CLI) is short-lived.
|
||||
store.checkpoint();
|
||||
match res {
|
||||
Ok(out) => println!("{out}"),
|
||||
Err(e) => {
|
||||
eprintln!("error: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let mut session = Session::new();
|
||||
match session.exec(&line) {
|
||||
Ok(out) => println!("{out}"),
|
||||
Err(e) => {
|
||||
eprintln!("error: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -144,6 +185,11 @@ fn print_help() {
|
||||
stat <path> getattr via cubefs\n \
|
||||
seal <path> <K.Z.Y.X|auto> <tf> encrypt a record (tf: none|gcm|chacha|xts)\n \
|
||||
open <path> <K.Z.Y.X|auto> <tf> decrypt + decode + run a sealed record\n \
|
||||
keyinit ensure the OS Null-space keystore exists\n"
|
||||
keyinit ensure the OS Null-space keystore exists\n \
|
||||
note <text> add a session note (durable, WordFlags-tagged)\n \
|
||||
note list [session] list notes (default: today's session)\n \
|
||||
note search <text> search notes across sessions\n \
|
||||
note show <C.Z.Y.X> show one note\n \
|
||||
notes alias for `note list`\n"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -507,6 +507,11 @@ impl Session {
|
||||
body.len()))
|
||||
}
|
||||
}
|
||||
// Session-message / note log: a searchable, per-session, per-day
|
||||
// note area stored as CZYX records carrying WordFlags metadata
|
||||
// (the structured replacement for the hand-maintained RESUME-*.md
|
||||
// files and the Hermes session store).
|
||||
"note" | "notes" => crate::notes::note_command(store, line),
|
||||
"trace-capture" => {
|
||||
let src_s = it.next().ok_or_else(|| "trace-capture needs <source C.Z.Y.X> <target C.Z.Y.X>".to_string())?;
|
||||
let tgt_s = it.next().ok_or_else(|| "trace-capture needs <target C.Z.Y.X>".to_string())?;
|
||||
|
||||
@@ -54,6 +54,11 @@ pub mod commands;
|
||||
pub mod grants;
|
||||
/// Length-framed Unix-domain-socket transport shared by client and server.
|
||||
pub mod net;
|
||||
/// Session-message / note log: a searchable, per-session, per-day note area
|
||||
/// stored as CZYX records carrying WordFlags metadata. This is the structured
|
||||
/// replacement for the hand-maintained `RESUME-*.md` files so a new session
|
||||
/// can review prior sessions' messages over the cube store (see [`notes`]).
|
||||
pub mod notes;
|
||||
/// Dependency-free JSON snapshot load/dump for daemon store persistence.
|
||||
pub mod persist;
|
||||
/// Concurrent, durable store: mutex-wrapped [`CubeStore`] + NDJSON write-ahead
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
//! CUBELinux session-message / note log.
|
||||
//!
|
||||
//! A small, searchable note area backed by the cube store instead of the
|
||||
//! growing hand-maintained `RESUME-*.md` files. Each note is a CZYX record
|
||||
//! with `doc_type = "note"`, a text-type [`WordFlags`] stamp, a `created_at`
|
||||
//! day, and the session label as the owner, so a new session can review prior
|
||||
//! sessions' messages by scanning the note type, filtering by session/day, or
|
||||
//! full-text searching — the "message save path" helper.
|
||||
//!
|
||||
//! Convention:
|
||||
//! * `doc_type = "note"` (all notes).
|
||||
//! * `created_at` (epoch seconds) groups the note into a day.
|
||||
//! * `owner_local_user` holds the session label.
|
||||
//! * `word_flags` = a complete single-record frame + text-type note.
|
||||
//! * The C axis is a per-session class byte so `scan_prefix` can return a
|
||||
//! session's notes; Z/Y/X are content-derived (collision-resolved).
|
||||
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use cubecoords::{CubeHeader, Czyx, WordFlags};
|
||||
|
||||
use crate::store::ConcurrentStore;
|
||||
|
||||
/// `doc_type` stamped on every note record.
|
||||
pub const NOTE_DOC_TYPE: &str = "note";
|
||||
|
||||
/// The default session label for CLI-created notes (today's calendar date).
|
||||
pub fn default_session() -> String {
|
||||
date_string(now())
|
||||
}
|
||||
|
||||
/// The WordFlags stamped on a note: a complete single-record frame with the
|
||||
/// text data type (type bits 00). Other metadata bits (encrypted, assoc, ...)
|
||||
/// are left unset for the caller to raise as needed.
|
||||
pub fn note_word_flags() -> WordFlags {
|
||||
WordFlags::from_bits(
|
||||
WordFlags::START_RECORD | WordFlags::END_RECORD | WordFlags::IS_HEADER,
|
||||
)
|
||||
}
|
||||
|
||||
/// Current unix time (seconds).
|
||||
pub fn now() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// A returned note (coordinate + header + body bytes).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Note {
|
||||
/// The note's coordinate (`C.Z.Y.X`).
|
||||
pub coord: Czyx,
|
||||
/// The note's record header (title, session label, WordFlags, ...).
|
||||
pub header: CubeHeader,
|
||||
/// The note's body text as raw bytes.
|
||||
pub body: Vec<u8>,
|
||||
}
|
||||
|
||||
fn fnv1a(bytes: &[u8]) -> u64 {
|
||||
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
|
||||
for &b in bytes {
|
||||
h ^= b as u64;
|
||||
h = h.wrapping_mul(0x0000_0100_0000_01b3);
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
fn non_zero(b: u8) -> u8 {
|
||||
if b == 0 {
|
||||
1
|
||||
} else {
|
||||
b
|
||||
}
|
||||
}
|
||||
|
||||
/// The single-byte notes class for a session label, so `scan_prefix(class,
|
||||
/// None, None)` returns exactly that session's notes.
|
||||
fn session_class(session: &str) -> u8 {
|
||||
let h = fnv1a(session.as_bytes());
|
||||
1 + ((h >> 24) as u8 & 0x7f) // 1..=128
|
||||
}
|
||||
|
||||
/// Derive a deterministic, collision-light coordinate for a note: the C axis
|
||||
/// is the session class, Z/Y/X come from the note content + a salt.
|
||||
fn note_coord(session: &str, created: u64, subject: &str, body: &str, salt: u64) -> Czyx {
|
||||
let mut mix = fnv1a(session.as_bytes());
|
||||
mix ^= created.wrapping_mul(0x9e37_79b9);
|
||||
mix ^= fnv1a(subject.as_bytes());
|
||||
mix ^= fnv1a(body.as_bytes());
|
||||
mix ^= salt.wrapping_mul(0x85eb_ca6b);
|
||||
Czyx::new(
|
||||
session_class(session),
|
||||
non_zero((mix >> 16) as u8),
|
||||
non_zero((mix >> 8) as u8),
|
||||
non_zero(mix as u8 ^ (salt as u8)),
|
||||
)
|
||||
}
|
||||
|
||||
/// Store a note record under `session`, with a `subject` title and `body`
|
||||
/// text. Returns the note's coordinate.
|
||||
pub fn store_note(
|
||||
store: &ConcurrentStore,
|
||||
session: &str,
|
||||
subject: &str,
|
||||
body: &str,
|
||||
) -> Result<Czyx, String> {
|
||||
let created = now();
|
||||
let mut salt = 0u64;
|
||||
let mut coord = note_coord(session, created, subject, body, salt);
|
||||
while store.get_record(&coord).is_some() && salt < 4096 {
|
||||
salt += 1;
|
||||
coord = note_coord(session, created, subject, body, salt);
|
||||
}
|
||||
if salt >= 4096 {
|
||||
return Err("note store: could not find a free coordinate (4096 tries)".into());
|
||||
}
|
||||
let mut h = CubeHeader::new();
|
||||
h.title = Some(subject.to_string());
|
||||
h.doc_type = Some(NOTE_DOC_TYPE.to_string());
|
||||
h.created_at = Some(created);
|
||||
h.owner_local_user = Some(session.to_string());
|
||||
h.word_flags = note_word_flags();
|
||||
h.refresh_flags();
|
||||
store.put_record(coord, &h, body.as_bytes());
|
||||
Ok(coord)
|
||||
}
|
||||
|
||||
/// List notes, newest first. `session` filters to one session label; `day`
|
||||
/// (a day-of-epoch, see [`date_to_day`]) filters to one calendar day.
|
||||
pub fn list_notes(
|
||||
store: &ConcurrentStore,
|
||||
session: Option<&str>,
|
||||
day: Option<u64>,
|
||||
) -> Vec<Note> {
|
||||
let snap = store.read_snapshot();
|
||||
let mut out: Vec<Note> = snap
|
||||
.query_by_type(NOTE_DOC_TYPE)
|
||||
.into_iter()
|
||||
.filter(|(_, h, _)| match session {
|
||||
Some(s) => h.owner_local_user.as_deref() == Some(s),
|
||||
None => true,
|
||||
})
|
||||
.filter(|(_, h, _)| match day {
|
||||
Some(d) => h.created_at.map(|c| c / 86_400) == Some(d),
|
||||
None => true,
|
||||
})
|
||||
.map(|(coord, header, body)| Note {
|
||||
coord,
|
||||
header,
|
||||
body,
|
||||
})
|
||||
.collect();
|
||||
out.sort_by(|a, b| b.header.created_at.cmp(&a.header.created_at));
|
||||
out
|
||||
}
|
||||
|
||||
/// Search all notes (session + body) for `needle`, newest first.
|
||||
pub fn search_notes(store: &ConcurrentStore, needle: &str) -> Vec<Note> {
|
||||
let needle = needle.to_lowercase();
|
||||
let snap = store.read_snapshot();
|
||||
let mut out: Vec<Note> = snap
|
||||
.query_by_type(NOTE_DOC_TYPE)
|
||||
.into_iter()
|
||||
.filter(|(_, h, body)| {
|
||||
let subject = h.title.as_deref().unwrap_or("").to_lowercase();
|
||||
let b = String::from_utf8_lossy(body).to_lowercase();
|
||||
subject.contains(&needle) || b.contains(&needle)
|
||||
})
|
||||
.map(|(coord, header, body)| Note {
|
||||
coord,
|
||||
header,
|
||||
body,
|
||||
})
|
||||
.collect();
|
||||
out.sort_by(|a, b| b.header.created_at.cmp(&a.header.created_at));
|
||||
out
|
||||
}
|
||||
|
||||
/// Fetch a single note by coordinate.
|
||||
pub fn show_note(store: &ConcurrentStore, coord: Czyx) -> Option<Note> {
|
||||
store.get_record(&coord).map(|(header, body)| Note {
|
||||
coord,
|
||||
header,
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
/// Format a unix timestamp as `YYYY-MM-DD` (UTC).
|
||||
pub fn date_string(ts: u64) -> String {
|
||||
let days = (ts / 86_400) as i64;
|
||||
let (y, m, d) = civil_from_days(days);
|
||||
format!("{y:04}-{m:02}-{d:02}")
|
||||
}
|
||||
|
||||
/// Parse `YYYY-MM-DD` into a day-of-epoch (days since 1970-01-01).
|
||||
pub fn date_to_day(s: &str) -> Option<u64> {
|
||||
let mut it = s.split('-');
|
||||
let y: i64 = it.next()?.parse().ok()?;
|
||||
let m: u32 = it.next()?.parse().ok()?;
|
||||
let d: u32 = it.next()?.parse().ok()?;
|
||||
if it.next().is_some() || !(1..=12).contains(&m) || !(1..=31).contains(&d) {
|
||||
return None;
|
||||
}
|
||||
Some(days_from_civil(y, m, d))
|
||||
}
|
||||
|
||||
/// Days since 1970-01-01 for a civil date (Howard Hinnant's algorithm).
|
||||
fn days_from_civil(y: i64, m: u32, d: u32) -> u64 {
|
||||
let yy = if m <= 2 { y - 1 } else { y };
|
||||
let era = if yy >= 0 { yy } else { yy - 399 } / 400;
|
||||
let yoe = yy - era * 400;
|
||||
let mp = (m as i64 + 9) % 12;
|
||||
let doy = (153 * mp + 2) / 5 + d as i64 - 1;
|
||||
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
|
||||
// Hinnant's `days_from_civil` counts from 1970-01-01; the era frame starts
|
||||
// at 0000-03-01, so subtract the 719468-day offset to land on the epoch.
|
||||
(era * 146_097 + doe - 719_468).max(0) as u64
|
||||
}
|
||||
|
||||
fn civil_from_days(days: i64) -> (i64, u32, u32) {
|
||||
let z = days + 719_468;
|
||||
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
|
||||
let doe = z - era * 146_097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let y = yoe + 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;
|
||||
(if m <= 2 { y + 1 } else { y }, m, d)
|
||||
}
|
||||
|
||||
/// Render a coordinate as `C.Z.Y.X` (the [`crate::commands::parse_coord`]
|
||||
/// form), so `note show <coord>` round-trips.
|
||||
fn coord_str(c: Czyx) -> String {
|
||||
format!("{}.{}.{}.{}", c.c, c.z, c.y, c.x)
|
||||
}
|
||||
|
||||
/// Subject = the first line-ish portion of the note text (first 60 chars).
|
||||
fn subject_of(text: &str) -> String {
|
||||
let s = text.trim();
|
||||
let s: String = s.chars().take(60).collect();
|
||||
if s.len() < text.trim().len() {
|
||||
format!("{s}…")
|
||||
} else {
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
fn format_notes(notes: &[Note]) -> String {
|
||||
if notes.is_empty() {
|
||||
return "(no notes)".to_string();
|
||||
}
|
||||
let lines: Vec<String> = notes
|
||||
.iter()
|
||||
.map(|n| {
|
||||
let subj = n.header.title.as_deref().unwrap_or("");
|
||||
let when = n.header.created_at.map(date_string).unwrap_or_else(|| "?".into());
|
||||
format!(" {} [{}] {}", coord_str(n.coord), when, subj)
|
||||
})
|
||||
.collect();
|
||||
format!("{} note(s):\n{}", notes.len(), lines.join("\n"))
|
||||
}
|
||||
|
||||
/// Process one `note`/`notes` command line against `store`.
|
||||
///
|
||||
/// Syntax (REPL/CLI):
|
||||
/// `note <text>` quick note (session = today, subject = first 60 chars)
|
||||
/// `note add <text>` explicit add (same as above)
|
||||
/// `note list [session]` list a session's notes (default: current day's session label, all sessions if empty)
|
||||
/// `note search <text>` full-text search across all notes
|
||||
/// `note show <C.Z.Y.X>` show one note's subject + body
|
||||
/// `notes` alias for `note list`
|
||||
pub fn note_command(store: &ConcurrentStore, line: &str) -> Result<String, String> {
|
||||
let body = line
|
||||
.splitn(2, char::is_whitespace)
|
||||
.nth(1)
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_string();
|
||||
let Some((head, rest)) = split_head(&body) else {
|
||||
// bare `note` / `notes` -> list the current session's notes
|
||||
let session = default_session();
|
||||
let notes = list_notes(store, Some(&session), None);
|
||||
return Ok(format_notes(¬es));
|
||||
};
|
||||
match head {
|
||||
"list" => {
|
||||
let s = rest.trim();
|
||||
let session = if s.is_empty() { None } else { Some(s) };
|
||||
let notes = list_notes(store, session, None);
|
||||
Ok(format_notes(¬es))
|
||||
}
|
||||
"search" => {
|
||||
let needle = rest.trim();
|
||||
if needle.is_empty() {
|
||||
return Err("note search needs <text>".to_string());
|
||||
}
|
||||
let notes = search_notes(store, needle);
|
||||
Ok(format!("search '{needle}':\n{}", format_notes(¬es)))
|
||||
}
|
||||
"show" => {
|
||||
let cs = rest.trim();
|
||||
let coord = crate::commands::parse_coord(cs)
|
||||
.ok_or_else(|| format!("bad coordinate '{cs}' (want C.Z.Y.X)"))?;
|
||||
match show_note(store, coord) {
|
||||
Some(n) => {
|
||||
let subj = n.header.title.as_deref().unwrap_or("");
|
||||
let when = n.header.created_at.map(date_string).unwrap_or_else(|| "?".into());
|
||||
Ok(format!(
|
||||
"note {cs} [{}] {}\n{}",
|
||||
when,
|
||||
subj,
|
||||
String::from_utf8_lossy(&n.body)
|
||||
))
|
||||
}
|
||||
None => Ok(format!("note {cs} -> (no note)")),
|
||||
}
|
||||
}
|
||||
"add" | _ => {
|
||||
let text = rest.trim();
|
||||
if text.is_empty() {
|
||||
return Err("note needs <text>".to_string());
|
||||
}
|
||||
let session = default_session();
|
||||
let subject = subject_of(text);
|
||||
let coord = store_note(store, &session, &subject, text)?;
|
||||
Ok(format!("note saved at {} (session {})", coord_str(coord), session))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Split the first whitespace-delimited token from the rest of a string.
|
||||
fn split_head(s: &str) -> Option<(&str, &str)> {
|
||||
let idx = s.find(char::is_whitespace)?;
|
||||
Some((&s[..idx], &s[idx..].trim()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn store_and_list_a_note() {
|
||||
let store = ConcurrentStore::memory();
|
||||
let coord = store_note(&store, "test-session", "hello", "world note").unwrap();
|
||||
assert!(store.get_record(&coord).is_some());
|
||||
let all = list_notes(&store, None, None);
|
||||
assert_eq!(all.len(), 1);
|
||||
assert_eq!(all[0].header.doc_type.as_deref(), Some(NOTE_DOC_TYPE));
|
||||
assert_eq!(all[0].header.owner_local_user.as_deref(), Some("test-session"));
|
||||
assert_eq!(all[0].body, b"world note");
|
||||
// word_flags: text-type single-frame
|
||||
assert!(all[0].header.word_flags.has(WordFlags::START_RECORD));
|
||||
assert!(!all[0].header.word_flags.has(WordFlags::ENCRYPTED));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_and_day_filters() {
|
||||
let store = ConcurrentStore::memory();
|
||||
store_note(&store, "a", "one", "first").unwrap();
|
||||
store_note(&store, "b", "two", "second").unwrap();
|
||||
assert_eq!(list_notes(&store, Some("a"), None).len(), 1);
|
||||
assert_eq!(list_notes(&store, Some("b"), None).len(), 1);
|
||||
assert_eq!(list_notes(&store, None, None).len(), 2);
|
||||
let today = now() / 86_400;
|
||||
assert_eq!(list_notes(&store, None, Some(today)).len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_text_search_and_dates() {
|
||||
let store = ConcurrentStore::memory();
|
||||
store_note(&store, "s", "kernel", "the ipu4 camera driver linked").unwrap();
|
||||
store_note(&store, "s", "mail", "opendkim signing works").unwrap();
|
||||
assert_eq!(search_notes(&store, "ipu4").len(), 1);
|
||||
assert_eq!(search_notes(&store, "opendkim").len(), 1);
|
||||
assert_eq!(search_notes(&store, "nomatchxyz").len(), 0);
|
||||
assert_eq!(date_to_day("2026-09-08"), Some(20_704));
|
||||
assert_eq!(date_string(20_704 * 86_400), "2026-09-08");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user