refactor(session): flatten session_raw/, switch md to YYYY_MM_DD (#1098)

This commit is contained in:
Steven Enamakel
2026-05-02 12:58:09 -07:00
committed by GitHub
parent 416447d478
commit 5523434516
9 changed files with 797 additions and 86 deletions
+1
View File
@@ -212,6 +212,7 @@ Tauri/Rust in the shell is a **delivery vehicle** (windowing, process lifecycle,
- Issues and PRs on upstream **[tinyhumansai/openhuman](https://github.com/tinyhumansai/openhuman)** — not a fork — unless explicitly told otherwise.
- Issue templates: [`.github/ISSUE_TEMPLATE/feature.md`](.github/ISSUE_TEMPLATE/feature.md), [`.github/ISSUE_TEMPLATE/bug.md`](.github/ISSUE_TEMPLATE/bug.md). PR template: [`.github/PULL_REQUEST_TEMPLATE.md`](.github/PULL_REQUEST_TEMPLATE.md). AI-authored text should follow them verbatim.
- PRs target **`main`**.
- **Push branches to `origin` (the user's fork — `senamakel/openhuman`), never to `upstream` (`tinyhumansai/openhuman`).** PRs are still opened against `tinyhumansai/openhuman:main`, but with `--head senamakel:<branch>` so the source is the fork. Direct pushes to upstream pollute its branch list and skip code-review boundaries. Treat the `upstream` remote as fetch-only.
- **When the user asks you to push or open a PR, resolve blockers and push — don't prompt for permission.** If a pre-push hook fails on something unrelated to your changes (e.g. pre-existing breakage on `main` in code you didn't touch), push with `--no-verify` and call it out in the PR body. If the hook fails on your own changes, fix them and push again. Don't ask the user whether to bypass — just do the right thing and tell them what you did.
---
Generated
+1 -1
View File
@@ -4568,7 +4568,7 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "openhuman"
version = "0.53.4"
version = "0.53.8"
dependencies = [
"aes-gcm",
"anyhow",
+35
View File
@@ -919,6 +919,41 @@ pub async fn bootstrap_skill_runtime() {
);
}
// --- Session storage layout migration -------------------------------
// One-shot move from `session_raw/{DDMMYYYY}/` (≤ 0.53.4) to the new
// flat `session_raw/{stem}.jsonl` layout, plus DDMMYYYY → YYYY_MM_DD
// for the human-readable `sessions/` companions. Idempotent via a
// marker file at `state/migrations/session_layout_v1.done`, so this
// costs one stat() on every subsequent boot.
match crate::openhuman::agent::harness::session::migrate_session_layout_if_needed(
&workspace_dir,
) {
Ok(outcome) if outcome.already_done => {
log::debug!("[runtime] session_layout migration already applied");
}
Ok(outcome) => {
log::info!(
"[runtime] session_layout migration applied: jsonl_moved={} md_moved={} pruned_dirs={} warnings={}",
outcome.jsonl_moved,
outcome.md_moved,
outcome.legacy_dirs_pruned,
outcome.warnings.len(),
);
for w in &outcome.warnings {
log::warn!("[runtime] session_layout migration warning: {w}");
}
}
Err(err) => {
// Don't bring down startup over a transcript-storage migration.
// The transcript module's legacy fallback covers the unmigrated
// case for one release window.
log::warn!(
"[runtime] session_layout migration failed: {err} — \
falling back to in-place legacy reads"
);
}
}
// --- Socket manager bootstrap ---
let socket_mgr = Arc::new(SocketManager::new());
set_global_socket_manager(socket_mgr.clone());
@@ -0,0 +1,373 @@
//! Session storage layout migration: date-grouped → flat `session_raw/`.
//!
//! Older releases (≤ 0.53.4) wrote transcripts to:
//!
//! ```text
//! {workspace}/session_raw/{DDMMYYYY}/{stem}.jsonl
//! {workspace}/sessions/{DDMMYYYY}/{stem}.md
//! ```
//!
//! From 0.53.5 onwards the source of truth is the *flat*
//! `session_raw/{stem}.jsonl` and the human-readable companion is
//! `sessions/{YYYY_MM_DD}/{stem}.md` — see
//! [`super::transcript`] for the rationale (idle-thread resume
//! becomes date-independent).
//!
//! `find_latest_transcript` ships a fallback that reads the legacy
//! layout when the flat dir is empty, so users upgrading don't lose
//! resume even before this migration runs. This module performs the
//! one-shot move so files end up in their canonical location and the
//! transitional fallback can eventually be removed.
//!
//! ## Idempotency
//!
//! After a successful migration we write a marker at
//! `{workspace}/state/migrations/session_layout_v1.done`. Subsequent
//! starts read the marker and skip the scan entirely. If the workspace
//! has no legacy layout (fresh install or already migrated by an
//! external sync) we still write the marker so the scan stays
//! single-cost.
//!
//! ## Version gate
//!
//! The marker doubles as the "have we already migrated past 0.53.4?"
//! flag. A bare workspace with no legacy dirs and no marker is treated
//! as "fresh — nothing to do, write the marker." A workspace with
//! legacy dirs is treated as "upgrading from ≤ 0.53.4 — migrate then
//! write the marker."
//!
//! Failures are surfaced as warnings (logged) and **never panic**:
//! transcript files are valuable but not strictly required for
//! continued operation, and an uncatchable migration error would brick
//! every startup.
use anyhow::{Context, Result};
use std::fs;
use std::path::{Path, PathBuf};
/// Marker file that signals "the v1 session-layout migration ran
/// successfully on this workspace at least once". Written under
/// `state/migrations/` to keep the workspace root tidy.
const MIGRATION_MARKER: &str = "state/migrations/session_layout_v1.done";
#[derive(Debug, Default, Clone)]
pub struct MigrationOutcome {
pub jsonl_moved: usize,
pub jsonl_skipped: usize,
pub md_moved: usize,
pub md_skipped: usize,
pub legacy_dirs_pruned: usize,
pub already_done: bool,
pub warnings: Vec<String>,
}
/// Migrate the session storage layout for `workspace_dir` if needed.
///
/// * Detects legacy `session_raw/{DDMMYYYY}/...jsonl` and
/// `sessions/{DDMMYYYY}/...md` layouts (i.e. an upgrade from
/// ≤ 0.53.4).
/// * Moves jsonl files to flat `session_raw/{stem}.jsonl`.
/// * Renames `DDMMYYYY` md dirs to ISO-style `YYYY_MM_DD` so the
/// listing sorts lexicographically.
/// * Writes the migration marker on success.
///
/// Idempotent: returns immediately with `already_done = true` if the
/// marker already exists. Best-effort on individual file moves —
/// failures are logged and surfaced via `warnings`, not propagated, so
/// one bad rename can't brick startup.
pub fn migrate_session_layout_if_needed(workspace_dir: &Path) -> Result<MigrationOutcome> {
let marker_path = workspace_dir.join(MIGRATION_MARKER);
if marker_path.exists() {
log::debug!(
"[session-migration] marker present at {} — skipping",
marker_path.display()
);
return Ok(MigrationOutcome {
already_done: true,
..Default::default()
});
}
let mut outcome = MigrationOutcome::default();
let raw_root = workspace_dir.join("session_raw");
if raw_root.is_dir() {
migrate_raw_jsonl(&raw_root, &mut outcome)?;
}
let sessions_root = workspace_dir.join("sessions");
if sessions_root.is_dir() {
migrate_md_directories(&sessions_root, &mut outcome)?;
}
write_marker(&marker_path, &outcome).context("write session-migration marker")?;
log::info!(
"[session-migration] complete: jsonl moved={} skipped={}, md moved={} skipped={}, legacy dirs pruned={}, warnings={}",
outcome.jsonl_moved,
outcome.jsonl_skipped,
outcome.md_moved,
outcome.md_skipped,
outcome.legacy_dirs_pruned,
outcome.warnings.len(),
);
Ok(outcome)
}
/// Walk `session_raw/`, find direct subdirectories whose names look
/// like `DDMMYYYY` (8 ascii digits), and move every `*.jsonl` file
/// inside up to the flat `session_raw/` parent. Empty legacy dirs
/// are removed; non-empty ones are left in place with a warning so a
/// human can decide what to do.
fn migrate_raw_jsonl(raw_root: &Path, outcome: &mut MigrationOutcome) -> Result<()> {
let entries = match fs::read_dir(raw_root) {
Ok(it) => it,
Err(err) => {
outcome
.warnings
.push(format!("read_dir({}) failed: {err}", raw_root.display()));
return Ok(());
}
};
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let Some(name) = path.file_name().and_then(|s| s.to_str()) else {
continue;
};
if !is_ddmmyyyy(name) {
// Not a legacy date dir — leave alone (could be the new
// flat layout's own files which would never be a dir, or
// a user-created subdirectory we shouldn't touch).
continue;
}
move_jsonl_files_up(&path, raw_root, outcome);
prune_if_empty(&path, outcome);
}
Ok(())
}
fn move_jsonl_files_up(legacy_dir: &Path, flat_dir: &Path, outcome: &mut MigrationOutcome) {
let entries = match fs::read_dir(legacy_dir) {
Ok(it) => it,
Err(err) => {
outcome
.warnings
.push(format!("read_dir({}) failed: {err}", legacy_dir.display()));
return;
}
};
for entry in entries.flatten() {
let path = entry.path();
if !path.is_file() {
continue;
}
if path.extension().and_then(|s| s.to_str()) != Some("jsonl") {
continue;
}
let Some(file_name) = path.file_name() else {
continue;
};
let dest = flat_dir.join(file_name);
if dest.exists() {
// Same stem already lives in the flat dir — the new layout
// is authoritative for current sessions, so leave the
// legacy copy in place and surface a warning instead of
// overwriting newer data.
outcome.jsonl_skipped += 1;
outcome.warnings.push(format!(
"skip move: destination already exists at {} (legacy file kept at {})",
dest.display(),
path.display()
));
continue;
}
match fs::rename(&path, &dest) {
Ok(()) => {
outcome.jsonl_moved += 1;
log::debug!(
"[session-migration] moved {} → {}",
path.display(),
dest.display()
);
}
Err(err) => {
outcome.warnings.push(format!(
"rename({}{}) failed: {err}",
path.display(),
dest.display()
));
}
}
}
}
/// Walk `sessions/`, rename each `DDMMYYYY` subdirectory to its
/// `YYYY_MM_DD` equivalent. We rename the dir wholesale rather than
/// copying file-by-file: the contents are human-readable companions
/// and don't need re-indexing.
fn migrate_md_directories(sessions_root: &Path, outcome: &mut MigrationOutcome) -> Result<()> {
let entries = match fs::read_dir(sessions_root) {
Ok(it) => it,
Err(err) => {
outcome.warnings.push(format!(
"read_dir({}) failed: {err}",
sessions_root.display()
));
return Ok(());
}
};
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let Some(name) = path.file_name().and_then(|s| s.to_str()) else {
continue;
};
let Some(iso) = ddmmyyyy_to_yyyy_mm_dd(name) else {
continue;
};
let dest = sessions_root.join(&iso);
if dest.exists() {
// ISO dir already exists — merge file-by-file, never
// overwrite. A user could have legitimately produced both
// names (e.g. by manual workflow) so we don't blindly
// discard either side.
merge_md_dirs(&path, &dest, outcome);
prune_if_empty(&path, outcome);
continue;
}
match fs::rename(&path, &dest) {
Ok(()) => {
outcome.md_moved += 1;
log::debug!(
"[session-migration] renamed md dir {} → {}",
path.display(),
dest.display()
);
}
Err(err) => {
outcome.warnings.push(format!(
"rename({}{}) failed: {err}",
path.display(),
dest.display()
));
}
}
}
Ok(())
}
fn merge_md_dirs(legacy: &Path, dest: &Path, outcome: &mut MigrationOutcome) {
let entries = match fs::read_dir(legacy) {
Ok(it) => it,
Err(err) => {
outcome
.warnings
.push(format!("read_dir({}) failed: {err}", legacy.display()));
return;
}
};
for entry in entries.flatten() {
let src = entry.path();
if !src.is_file() {
continue;
}
let Some(file_name) = src.file_name() else {
continue;
};
let target = dest.join(file_name);
if target.exists() {
outcome.md_skipped += 1;
outcome.warnings.push(format!(
"skip md merge: {} already exists (legacy at {} kept)",
target.display(),
src.display()
));
continue;
}
match fs::rename(&src, &target) {
Ok(()) => outcome.md_moved += 1,
Err(err) => outcome.warnings.push(format!(
"rename({}{}) failed: {err}",
src.display(),
target.display()
)),
}
}
}
fn prune_if_empty(dir: &Path, outcome: &mut MigrationOutcome) {
match fs::read_dir(dir) {
Ok(mut it) => {
if it.next().is_some() {
// Non-empty — leave for human inspection.
return;
}
}
Err(_) => return,
}
if fs::remove_dir(dir).is_ok() {
outcome.legacy_dirs_pruned += 1;
}
}
fn write_marker(marker_path: &Path, outcome: &MigrationOutcome) -> Result<()> {
if let Some(parent) = marker_path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("create marker dir {}", parent.display()))?;
}
let body = format!(
"openhuman session_layout migration v1\nrun_at: {}\njsonl_moved: {}\nmd_moved: {}\nlegacy_dirs_pruned: {}\nwarnings: {}\n",
chrono::Utc::now().to_rfc3339(),
outcome.jsonl_moved,
outcome.md_moved,
outcome.legacy_dirs_pruned,
outcome.warnings.len(),
);
fs::write(marker_path, body)
.with_context(|| format!("write marker {}", marker_path.display()))?;
Ok(())
}
/// Returns true iff `name` is exactly 8 ASCII digits — the legacy
/// `DDMMYYYY` shape. We don't validate the date range (131, 112,
/// 19002100) because chrono printed the value originally, so any
/// real on-disk dir is well-formed; the digit shape is a sufficient
/// fingerprint to distinguish from user-created subdirectories.
fn is_ddmmyyyy(name: &str) -> bool {
name.len() == 8 && name.chars().all(|c| c.is_ascii_digit())
}
/// Convert `DDMMYYYY` → `YYYY_MM_DD`. Returns `None` if the input
/// isn't 8 digits.
fn ddmmyyyy_to_yyyy_mm_dd(name: &str) -> Option<String> {
if !is_ddmmyyyy(name) {
return None;
}
let dd = &name[0..2];
let mm = &name[2..4];
let yyyy = &name[4..8];
Some(format!("{yyyy}_{mm}_{dd}"))
}
/// Returns the path of the migration marker for `workspace_dir`.
/// Exposed for tests and CLI tooling that wants to manually re-run
/// the migration (delete the marker, then call
/// [`migrate_session_layout_if_needed`] again).
pub fn marker_path_for(workspace_dir: &Path) -> PathBuf {
workspace_dir.join(MIGRATION_MARKER)
}
#[cfg(test)]
#[path = "migration_tests.rs"]
mod tests;
@@ -0,0 +1,170 @@
use super::*;
use std::fs;
use tempfile::TempDir;
fn write_file(path: &std::path::Path, body: &str) {
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, body).unwrap();
}
#[test]
fn fresh_workspace_writes_marker_with_no_moves() {
let dir = TempDir::new().unwrap();
let outcome = migrate_session_layout_if_needed(dir.path()).unwrap();
assert!(!outcome.already_done);
assert_eq!(outcome.jsonl_moved, 0);
assert_eq!(outcome.md_moved, 0);
assert!(marker_path_for(dir.path()).exists());
}
#[test]
fn second_run_is_a_noop() {
let dir = TempDir::new().unwrap();
let _first = migrate_session_layout_if_needed(dir.path()).unwrap();
let second = migrate_session_layout_if_needed(dir.path()).unwrap();
assert!(second.already_done);
assert_eq!(second.jsonl_moved, 0);
assert_eq!(second.warnings.len(), 0);
}
#[test]
fn moves_legacy_jsonl_files_up_to_flat_session_raw() {
let dir = TempDir::new().unwrap();
let ws = dir.path();
let legacy_a = ws.join("session_raw").join("01052026");
let legacy_b = ws.join("session_raw").join("02052026");
write_file(&legacy_a.join("1714000000_main.jsonl"), "a");
write_file(&legacy_a.join("1714000001_welcome.jsonl"), "b");
write_file(&legacy_b.join("1714999999_orchestrator.jsonl"), "c");
let outcome = migrate_session_layout_if_needed(ws).unwrap();
assert_eq!(outcome.jsonl_moved, 3);
assert_eq!(outcome.legacy_dirs_pruned, 2);
let raw_root = ws.join("session_raw");
assert!(raw_root.join("1714000000_main.jsonl").exists());
assert!(raw_root.join("1714000001_welcome.jsonl").exists());
assert!(raw_root.join("1714999999_orchestrator.jsonl").exists());
// Empty legacy date dirs should have been pruned.
assert!(!legacy_a.exists(), "legacy date dir should be removed");
assert!(!legacy_b.exists(), "legacy date dir should be removed");
}
#[test]
fn jsonl_destination_collision_is_skipped_with_warning() {
// If a flat `session_raw/{stem}.jsonl` already exists for the
// same stem we don't overwrite — the flat copy is authoritative
// (the user may have already started a fresh session with the
// same key after a clock reset). Surface a warning instead.
let dir = TempDir::new().unwrap();
let ws = dir.path();
let raw_root = ws.join("session_raw");
write_file(&raw_root.join("1714000000_main.jsonl"), "new");
write_file(
&raw_root.join("01052026").join("1714000000_main.jsonl"),
"old",
);
let outcome = migrate_session_layout_if_needed(ws).unwrap();
assert_eq!(outcome.jsonl_moved, 0);
assert_eq!(outcome.jsonl_skipped, 1);
assert!(outcome
.warnings
.iter()
.any(|w| w.contains("already exists")));
// Both files still exist — nothing was overwritten.
assert_eq!(
fs::read_to_string(raw_root.join("1714000000_main.jsonl")).unwrap(),
"new"
);
assert_eq!(
fs::read_to_string(raw_root.join("01052026").join("1714000000_main.jsonl")).unwrap(),
"old"
);
}
#[test]
fn renames_md_ddmmyyyy_dirs_to_iso() {
let dir = TempDir::new().unwrap();
let ws = dir.path();
let legacy_md = ws.join("sessions").join("01052026");
write_file(&legacy_md.join("main_0.md"), "x");
write_file(&legacy_md.join("main_1.md"), "y");
let outcome = migrate_session_layout_if_needed(ws).unwrap();
assert_eq!(outcome.md_moved, 1, "one rename of the dir as a whole");
let iso = ws.join("sessions").join("2026_05_01");
assert!(iso.is_dir());
assert!(iso.join("main_0.md").exists());
assert!(iso.join("main_1.md").exists());
assert!(
!legacy_md.exists(),
"DDMMYYYY md dir should be gone after rename"
);
}
#[test]
fn merges_md_when_iso_dir_already_exists() {
let dir = TempDir::new().unwrap();
let ws = dir.path();
// Both layouts coexist for the same calendar date — e.g. user
// ran a hand-edited build that produced ISO dirs alongside the
// legacy DDMMYYYY ones.
let legacy = ws.join("sessions").join("01052026");
let iso = ws.join("sessions").join("2026_05_01");
write_file(&legacy.join("main_0.md"), "legacy");
write_file(&legacy.join("main_1.md"), "legacy");
write_file(&iso.join("main_1.md"), "newer");
let outcome = migrate_session_layout_if_needed(ws).unwrap();
// main_0.md moves over (no collision); main_1.md collides and is
// skipped without overwriting the newer copy.
assert_eq!(outcome.md_moved, 1);
assert_eq!(outcome.md_skipped, 1);
assert_eq!(fs::read_to_string(iso.join("main_0.md")).unwrap(), "legacy");
assert_eq!(fs::read_to_string(iso.join("main_1.md")).unwrap(), "newer");
}
#[test]
fn ignores_non_date_subdirectories_in_session_raw() {
// Defensive: a user (or some other tool) might have created a
// sibling dir under session_raw/. We must not touch it — only
// 8-digit names are recognised as legacy date dirs.
let dir = TempDir::new().unwrap();
let ws = dir.path();
let weird = ws.join("session_raw").join("my_notes");
write_file(&weird.join("random.jsonl"), "keep me");
let outcome = migrate_session_layout_if_needed(ws).unwrap();
assert_eq!(outcome.jsonl_moved, 0);
assert!(weird.is_dir(), "non-date subdir must be left alone");
assert!(weird.join("random.jsonl").exists());
}
#[test]
fn ddmmyyyy_to_iso_handles_boundary_dates() {
assert_eq!(
ddmmyyyy_to_yyyy_mm_dd("01012026").as_deref(),
Some("2026_01_01")
);
assert_eq!(
ddmmyyyy_to_yyyy_mm_dd("31122099").as_deref(),
Some("2099_12_31")
);
assert!(ddmmyyyy_to_yyyy_mm_dd("abc12345").is_none());
assert!(ddmmyyyy_to_yyyy_mm_dd("1234567").is_none(), "7 digits");
assert!(ddmmyyyy_to_yyyy_mm_dd("123456789").is_none(), "9 digits");
}
#[test]
fn marker_persists_run_metadata() {
let dir = TempDir::new().unwrap();
let ws = dir.path();
let legacy = ws.join("session_raw").join("01052026");
write_file(&legacy.join("1714000000_main.jsonl"), "a");
migrate_session_layout_if_needed(ws).unwrap();
let marker = fs::read_to_string(marker_path_for(ws)).unwrap();
assert!(marker.contains("jsonl_moved: 1"));
assert!(marker.contains("openhuman session_layout migration v1"));
}
@@ -21,11 +21,14 @@
//! The child files are an implementation detail.
mod builder;
pub mod migration;
mod runtime;
pub(crate) mod transcript;
mod turn;
mod types;
pub use migration::{migrate_session_layout_if_needed, MigrationOutcome};
#[cfg(test)]
mod tests;
+110 -62
View File
@@ -1,12 +1,34 @@
//! Session transcript persistence for KV cache stability.
//!
//! **Source of truth**: `session_raw/{DDMMYYYY}/{agent}_{index}.jsonl`
//! **Source of truth**: `session_raw/{stem}.jsonl` — a *flat* directory.
//!
//! Each JSONL file starts with a single metadata line (identified by an
//! `_meta` key) followed by one JSON object per `ChatMessage`. On every
//! write the companion `.md` file is re-rendered for human readability
//! under `sessions/{DDMMYYYY}/{agent}_{index}.md`; it is **never** read
//! back — all round-trip / resume logic uses the JSONL.
//! under `sessions/{YYYY_MM_DD}/{stem}.md`; it is **never** read back —
//! all round-trip / resume logic uses the JSONL.
//!
//! ## Storage layout
//!
//! ```text
//! {workspace}/session_raw/{stem}.jsonl ← source of truth (flat)
//! {workspace}/sessions/YYYY_MM_DD/{stem}.md ← human-readable view
//! ```
//!
//! `stem` is `{unix_ts}_{agent_id}` for a root session, or
//! `{parent_chain}__{unix_ts}_{agent_id}` for a sub-agent. Because the
//! stem starts with the unix timestamp at agent-build time, a directory
//! listing of `session_raw/` is naturally sorted by creation time and
//! `find_latest_transcript` becomes O(scan one dir, filter by suffix)
//! — it does not depend on the calendar date, so a session that's been
//! idle for weeks resumes the same way as one from yesterday.
//!
//! ## Backward compatibility
//!
//! Older releases wrote into `session_raw/DDMMYYYY/{stem}.jsonl` (and
//! the legacy `sessions/DDMMYYYY/{stem}.md`). [`find_latest_transcript`]
//! falls back to scanning those date-grouped dirs when the flat
//! directory yields nothing, so users upgrading don't lose resume.
//!
//! ## JSONL schema
//!
@@ -25,13 +47,6 @@
//!
//! Only `role` and `content` are required. All other fields are optional.
//! Unknown fields on read are ignored (forward-compat).
//!
//! ## Storage layout
//!
//! ```text
//! {workspace}/session_raw/DDMMYYYY/{agent}_{index}.jsonl ← source of truth
//! {workspace}/sessions/DDMMYYYY/{agent}_{index}.md ← human-readable view
//! ```
use crate::openhuman::providers::ChatMessage;
use anyhow::{Context, Result};
@@ -363,25 +378,19 @@ fn read_transcript_jsonl(path: &Path) -> Result<SessionTranscript> {
// ── Path resolution ──────────────────────────────────────────────────
/// Resolve a new transcript path under
/// `{workspace}/session_raw/DDMMYYYY/{agent}_{index}.jsonl`.
/// Resolve a transcript path under `session_raw/{stem}.jsonl` — a
/// *flat* directory keyed only by stem. Used by the session-key flow:
/// the stem is `"{unix_ts}_{agent_id}"` for a root session, or
/// `"{parent_chain}__{session_key}"` for a sub-agent, so nested
/// delegations still produce a single flat filename that encodes the
/// parent → child path.
///
/// Creates the date directory if needed. Index = max existing + 1.
/// Scans both the new `session_raw/` dir (for `.jsonl`) **and** the legacy
/// `sessions/` dir (for `.md`) so indices stay unique across migration.
/// Resolve a transcript path under `session_raw/DDMMYYYY/{stem}.jsonl`
/// where `stem` is deterministic (no auto-indexing). Used by the new
/// session-key flow: the session-key stem is `"{unix_ts}_{agent_id}"`
/// for a root session, or `"{parent_chain}__{session_key}"` for a
/// sub-agent — so nested delegations produce a single flat filename
/// that encodes the parent → child path.
///
/// Creates the date directory if needed. Overwrites are intentional:
/// the Agent persists the same transcript file across every turn of a
/// Creates the directory if needed. Overwrites are intentional: the
/// `Agent` persists the same transcript file across every turn of a
/// session, and every sub-agent spawn gets a unique timestamp in its
/// own key so collisions are effectively impossible.
pub fn resolve_keyed_transcript_path(workspace_dir: &Path, stem: &str) -> Result<PathBuf> {
let raw_dir = today_raw_session_dir(workspace_dir);
let raw_dir = raw_session_dir(workspace_dir);
fs::create_dir_all(&raw_dir)
.with_context(|| format!("create session_raw dir {}", raw_dir.display()))?;
let sanitized = sanitize_stem(stem);
@@ -389,9 +398,9 @@ pub fn resolve_keyed_transcript_path(workspace_dir: &Path, stem: &str) -> Result
}
/// Sanitize a user-supplied transcript stem so it never escapes the
/// `session_raw/DDMMYYYY/` directory. Allows ASCII alphanumerics plus
/// a small punctuation set (`_`, `-`, `.`); every other byte is
/// replaced with `_`. Empty inputs fall back to `"session"`.
/// `session_raw/` directory. Allows ASCII alphanumerics plus a small
/// punctuation set (`_`, `-`, `.`); every other byte is replaced with
/// `_`. Empty inputs fall back to `"session"`.
fn sanitize_stem(stem: &str) -> String {
let cleaned: String = stem
.chars()
@@ -411,17 +420,17 @@ fn sanitize_stem(stem: &str) -> String {
}
pub fn resolve_new_transcript_path(workspace_dir: &Path, agent_name: &str) -> Result<PathBuf> {
let raw_dir = today_raw_session_dir(workspace_dir);
let raw_dir = raw_session_dir(workspace_dir);
fs::create_dir_all(&raw_dir)
.with_context(|| format!("create session_raw dir {}", raw_dir.display()))?;
let sanitized = sanitize_agent_name(agent_name);
let idx_raw = next_index(&raw_dir, &sanitized)?;
// Also consider the legacy sessions/ dir so companion .md (or legacy
// standalone .md) files don't cause index collisions.
let legacy_dir = today_session_dir(workspace_dir);
let idx_legacy = next_index(&legacy_dir, &sanitized)?;
let next_idx = idx_raw.max(idx_legacy);
// Also consider today's md companion dir so a stale .md from this
// session doesn't cause an index collision when only .md exists.
let md_dir = today_md_session_dir(workspace_dir);
let idx_md = next_index(&md_dir, &sanitized)?;
let next_idx = idx_raw.max(idx_md);
let filename = format!("{}_{}.jsonl", sanitized, next_idx);
Ok(raw_dir.join(filename))
@@ -429,28 +438,43 @@ pub fn resolve_new_transcript_path(workspace_dir: &Path, agent_name: &str) -> Re
/// Find the most recent transcript for `agent_name`.
///
/// Searches today's directory first, then yesterday's. Prefers `.jsonl`
/// under `session_raw/` and falls back to `.md` under `sessions/` when
/// only legacy files exist.
/// **Primary**: scan the flat `session_raw/` directory and pick the
/// newest matching stem (root sessions only — sub-agents are skipped).
/// **Fallback**: scan the legacy `session_raw/DDMMYYYY/` dirs (today
/// and yesterday) and the legacy `sessions/DDMMYYYY/` markdown dirs so
/// users upgrading from the date-grouped layout don't lose resume.
/// The fallback is one-release transitional and can be removed once
/// existing transcripts have rolled forward.
pub fn find_latest_transcript(workspace_dir: &Path, agent_name: &str) -> Option<PathBuf> {
let sanitized = sanitize_agent_name(agent_name);
let raw_root = workspace_dir.join("session_raw");
let sessions_root = workspace_dir.join("sessions");
// Primary path: flat session_raw/ directory. The stem-suffix scan
// is naturally date-independent, so an idle thread resumes the same
// way today as it did weeks ago.
if raw_root.is_dir() {
if let Some(path) = latest_in_dir(&raw_root, &sanitized) {
return Some(path);
}
}
// Fallback: legacy date-grouped layout (one-release migration
// window). Today first, then yesterday — matches the previous
// behaviour so we don't regress while users still have files in
// the old structure.
let today = chrono::Local::now().format("%d%m%Y").to_string();
let yesterday = (chrono::Local::now() - chrono::Duration::days(1))
.format("%d%m%Y")
.to_string();
for date_str in [&today, &yesterday] {
// Prefer the new session_raw/ location.
let raw_dir = raw_root.join(date_str);
if raw_dir.is_dir() {
if let Some(path) = latest_in_dir(&raw_dir, &sanitized) {
return Some(path);
}
}
// Fall back to legacy sessions/ for pre-migration .md only.
let legacy_dir = sessions_root.join(date_str);
if legacy_dir.is_dir() {
if let Some(path) = latest_in_dir(&legacy_dir, &sanitized) {
@@ -645,38 +669,62 @@ fn parse_legacy_messages(raw: &str) -> Result<Vec<ChatMessage>> {
// ── Private helpers ───────────────────────────────────────────────────
fn today_session_dir(workspace_dir: &Path) -> PathBuf {
let date = chrono::Local::now().format("%d%m%Y").to_string();
/// Date-grouped directory for human-readable `.md` companions, e.g.
/// `{workspace}/sessions/2026_05_02`. ISO-style `YYYY_MM_DD` so the
/// listing sorts lexicographically by date.
fn today_md_session_dir(workspace_dir: &Path) -> PathBuf {
let date = chrono::Local::now().format("%Y_%m_%d").to_string();
workspace_dir.join("sessions").join(date)
}
fn today_raw_session_dir(workspace_dir: &Path) -> PathBuf {
let date = chrono::Local::now().format("%d%m%Y").to_string();
workspace_dir.join("session_raw").join(date)
/// Flat directory for the JSONL source of truth, e.g.
/// `{workspace}/session_raw`. Stems start with `{unix_ts}` so the
/// listing is naturally time-ordered without a date subdirectory.
fn raw_session_dir(workspace_dir: &Path) -> PathBuf {
workspace_dir.join("session_raw")
}
/// Given a `session_raw/DDMMYYYY/agent_N.jsonl` path, derive the companion
/// `sessions/DDMMYYYY/agent_N.md` path by swapping the `session_raw`
/// path component with `sessions` and the extension with `.md`.
/// Given a `session_raw/{stem}.jsonl` path, derive the companion
/// `sessions/YYYY_MM_DD/{stem}.md` path. The date is taken from the
/// local clock at write time — fine for browsing because the source
/// of truth lives in the flat raw dir; the `.md` is purely a view.
///
/// If no `session_raw` component is present (e.g. tests using a flat
/// tempdir), just swap the extension so the companion lives alongside.
/// Legacy `session_raw/DDMMYYYY/{stem}.jsonl` paths (still on disk
/// from older releases until they roll forward) keep their date
/// component when generating the companion so we don't accidentally
/// stamp old transcripts with today's date.
///
/// If no `session_raw` component is present (tests using a flat
/// tempdir), the companion sits alongside as a sibling `.md`.
fn md_companion_path(jsonl_path: &Path) -> PathBuf {
let mut out = PathBuf::new();
let mut swapped = false;
for comp in jsonl_path.components() {
match comp {
std::path::Component::Normal(s) if s == "session_raw" => {
out.push("sessions");
swapped = true;
}
other => out.push(other.as_os_str()),
}
}
if !swapped {
// Fall back to sibling .md in the same directory.
let components: Vec<_> = jsonl_path.components().collect();
let raw_idx = components
.iter()
.position(|comp| matches!(comp, std::path::Component::Normal(s) if *s == "session_raw"));
let Some(raw_idx) = raw_idx else {
return jsonl_path.with_extension("md");
};
let mut out = PathBuf::new();
for comp in &components[..raw_idx] {
out.push(comp.as_os_str());
}
out.push("sessions");
// Tail after `session_raw`:
// * Flat: ["{stem}.jsonl"] — prepend today's YYYY_MM_DD.
// * Legacy: ["DDMMYYYY", "{stem}.jsonl"] — keep the existing
// date dir so we don't relabel old transcripts.
let tail = &components[raw_idx + 1..];
if tail.len() <= 1 {
out.push(chrono::Local::now().format("%Y_%m_%d").to_string());
}
for comp in tail {
out.push(comp.as_os_str());
}
out.with_extension("md")
}
@@ -105,32 +105,59 @@ fn meta_round_trip() {
}
#[test]
fn path_resolution_creates_dir_and_increments_index() {
fn path_resolution_creates_flat_session_raw_dir_and_increments_index() {
let dir = TempDir::new().unwrap();
let workspace = dir.path();
let path0 = resolve_new_transcript_path(workspace, "main").unwrap();
assert!(path0.to_string_lossy().contains("main_0.jsonl"));
// Flat layout: jsonl lives directly under session_raw/, no date dir.
let parent = path0.parent().unwrap();
assert!(
path0.to_string_lossy().contains("session_raw"),
"jsonl should live under session_raw/, got {}",
path0.display()
parent.ends_with("session_raw"),
"jsonl parent should be session_raw/ (flat layout), got {}",
parent.display()
);
// Write something so the next call sees it.
fs::write(&path0, "placeholder").unwrap();
let path1 = resolve_new_transcript_path(workspace, "main").unwrap();
assert!(path1.to_string_lossy().contains("main_1.jsonl"));
assert!(path1.parent().unwrap().ends_with("session_raw"));
}
#[test]
fn md_companion_path_swaps_session_raw_to_sessions() {
fn resolve_keyed_writes_to_flat_session_raw() {
let dir = TempDir::new().unwrap();
let path = resolve_keyed_transcript_path(dir.path(), "1714000000_orchestrator").unwrap();
assert_eq!(path.parent().unwrap(), dir.path().join("session_raw"));
assert!(path
.to_string_lossy()
.ends_with("1714000000_orchestrator.jsonl"));
}
#[test]
fn md_companion_path_for_flat_jsonl_uses_iso_date_dir() {
let jsonl = PathBuf::from("/tmp/ws/session_raw/1714000000_main.jsonl");
let md = md_companion_path(&jsonl);
let today = chrono::Local::now().format("%Y_%m_%d").to_string();
assert_eq!(
md,
PathBuf::from(format!("/tmp/ws/sessions/{today}/1714000000_main.md")),
"flat session_raw should map to sessions/YYYY_MM_DD/ on the md side"
);
}
#[test]
fn md_companion_path_preserves_legacy_ddmmyyyy_dir() {
// A pre-migration jsonl at session_raw/DDMMYYYY/{stem}.jsonl should
// keep its date component so old transcripts aren't relabeled with
// today's date.
let jsonl = PathBuf::from("/tmp/ws/session_raw/17042026/main_0.jsonl");
let md = md_companion_path(&jsonl);
assert_eq!(
md,
PathBuf::from("/tmp/ws/sessions/17042026/main_0.md"),
"md companion should live under sessions/ with .md extension"
"legacy date-grouped raw paths must keep their original date dir"
);
}
@@ -142,19 +169,19 @@ fn md_companion_path_falls_back_to_sibling_when_no_session_raw_component() {
}
#[test]
fn resolve_avoids_index_collision_with_legacy_md_in_sessions_dir() {
fn resolve_avoids_index_collision_with_md_in_iso_date_dir() {
let dir = TempDir::new().unwrap();
let workspace = dir.path();
let date = chrono::Local::now().format("%d%m%Y").to_string();
let legacy = workspace.join("sessions").join(&date);
fs::create_dir_all(&legacy).unwrap();
fs::write(legacy.join("main_0.md"), "legacy").unwrap();
fs::write(legacy.join("main_1.md"), "legacy").unwrap();
let date = chrono::Local::now().format("%Y_%m_%d").to_string();
let md_dir = workspace.join("sessions").join(&date);
fs::create_dir_all(&md_dir).unwrap();
fs::write(md_dir.join("main_0.md"), "x").unwrap();
fs::write(md_dir.join("main_1.md"), "x").unwrap();
let path = resolve_new_transcript_path(workspace, "main").unwrap();
assert!(
path.to_string_lossy().contains("main_2.jsonl"),
"should advance past legacy indices, got {}",
"should advance past md indices in today's YYYY_MM_DD dir, got {}",
path.display()
);
}
@@ -167,10 +194,9 @@ fn sanitize_agent_name_strips_special_chars() {
}
#[test]
fn find_latest_returns_highest_index() {
fn find_latest_scans_flat_session_raw_dir() {
let dir = TempDir::new().unwrap();
let date = chrono::Local::now().format("%d%m%Y").to_string();
let raw_dir = dir.path().join("session_raw").join(&date);
let raw_dir = dir.path().join("session_raw");
fs::create_dir_all(&raw_dir).unwrap();
fs::write(raw_dir.join("main_0.jsonl"), "a").unwrap();
@@ -178,11 +204,66 @@ fn find_latest_returns_highest_index() {
fs::write(raw_dir.join("main_1.jsonl"), "b").unwrap();
fs::write(raw_dir.join("other_0.jsonl"), "x").unwrap();
let latest = find_latest_transcript(dir.path(), "main");
assert!(latest.is_some());
let latest = latest.unwrap();
assert!(latest.to_string_lossy().contains("main_2.jsonl"));
assert!(latest.to_string_lossy().contains("session_raw"));
let latest = find_latest_transcript(dir.path(), "main").unwrap();
assert!(latest.to_string_lossy().ends_with("main_2.jsonl"));
assert_eq!(latest.parent().unwrap(), raw_dir);
}
#[test]
fn find_latest_picks_newest_keyed_stem_in_flat_dir() {
let dir = TempDir::new().unwrap();
let raw_dir = dir.path().join("session_raw");
fs::create_dir_all(&raw_dir).unwrap();
// Keyed stem layout: `{unix_ts}_{agent_id}.jsonl`.
fs::write(raw_dir.join("1714000000_main.jsonl"), "old").unwrap();
fs::write(raw_dir.join("1714999999_main.jsonl"), "new").unwrap();
// Sub-agent transcripts (contain `__`) must be skipped.
fs::write(
raw_dir.join("1714000000_orchestrator__1714500000_planner.jsonl"),
"sub",
)
.unwrap();
let latest = find_latest_transcript(dir.path(), "main").unwrap();
assert!(latest.to_string_lossy().ends_with("1714999999_main.jsonl"));
}
#[test]
fn find_latest_falls_back_to_legacy_ddmmyyyy_raw_dir() {
// Pre-migration transcript at session_raw/DDMMYYYY/main_*.jsonl
// must still resolve via the legacy fallback when the flat dir is
// empty.
let dir = TempDir::new().unwrap();
let date = chrono::Local::now().format("%d%m%Y").to_string();
let legacy_raw = dir.path().join("session_raw").join(&date);
fs::create_dir_all(&legacy_raw).unwrap();
fs::write(legacy_raw.join("main_5.jsonl"), "legacy").unwrap();
let latest = find_latest_transcript(dir.path(), "main").unwrap();
assert!(latest.to_string_lossy().ends_with("main_5.jsonl"));
assert!(latest.to_string_lossy().contains(&date));
}
#[test]
fn find_latest_prefers_flat_over_legacy_ddmmyyyy() {
let dir = TempDir::new().unwrap();
let raw_root = dir.path().join("session_raw");
fs::create_dir_all(&raw_root).unwrap();
fs::write(raw_root.join("main_9.jsonl"), "flat").unwrap();
let date = chrono::Local::now().format("%d%m%Y").to_string();
let legacy_raw = raw_root.join(&date);
fs::create_dir_all(&legacy_raw).unwrap();
fs::write(legacy_raw.join("main_99.jsonl"), "legacy").unwrap();
let latest = find_latest_transcript(dir.path(), "main").unwrap();
// Flat dir takes precedence so newly-created sessions always win
// over stale legacy files — even when a legacy file has a higher
// numeric index. The flat dir is the canonical layout going
// forward.
assert_eq!(latest.parent().unwrap(), raw_root);
assert!(latest.to_string_lossy().ends_with("main_9.jsonl"));
}
#[test]