feat: Session(Segment 群の grouping)を導入

- SessionId 型を新設、各 SegmentStart に session_id を持たせる
- compaction / 内部 fork は同 SessionId を継承、fork() は新 Session を発行
- Store API を (SessionId, SegmentId) ベースに、FsStore layout は
  <root>/<session_id>/<segment_id>.jsonl に
- Store::list_sessions / list_segments(session_id) / lookup_session_of を追加
- restore_by_segment shim を session-store に提供(pod-cli --session で使用)
- SegmentState に SegmentLocation (session_id, segment_id) を保持し ArcSwap で更新
- RestoredState に session_id: Option<SessionId> を追加
- Picker は Session 単位に列挙、leaf segment を解決して resume
This commit is contained in:
2026-05-20 06:17:56 +09:00
parent d2b3c2f53d
commit 5edc4d3b03
18 changed files with 715 additions and 316 deletions
+1
View File
@@ -1447,6 +1447,7 @@ mod completion_flow_tests {
let mut app = App::new("test".into());
let session_start = session_store::LogEntry::SegmentStart {
ts: 1,
session_id: uuid::Uuid::nil(),
system_prompt: None,
config: Default::default(),
history: vec![session_store::LoggedItem::from(
+3 -3
View File
@@ -209,11 +209,11 @@ async fn run_resume() -> Result<(), Box<dyn std::error::Error>> {
// Phase 1: pick a session in its own inline viewport, dropping the
// viewport before the name dialog opens so each phase gets fresh
// vertical room.
let id = match picker::run().await? {
PickerOutcome::Picked(id) => id,
let leaf_segment_id = match picker::run().await? {
PickerOutcome::Picked { segment_id, .. } => segment_id,
PickerOutcome::Cancelled => return Ok(()),
};
run_spawn(Some(id)).await
run_spawn(Some(leaf_segment_id)).await
}
async fn run_spawn(resume_from: Option<SegmentId>) -> Result<(), Box<dyn std::error::Error>> {
+42 -19
View File
@@ -20,7 +20,9 @@ use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use ratatui::{Frame, TerminalOptions, Viewport};
use session_store::{FsStore, LogEntry, LoggedContentPart, LoggedItem, SegmentId, Store};
use session_store::{
FsStore, LogEntry, LoggedContentPart, LoggedItem, SegmentId, SessionId, Store,
};
const MAX_ROWS: usize = 10;
const VIEWPORT_LINES: u16 = MAX_ROWS as u16 + 4;
@@ -60,38 +62,55 @@ impl From<session_store::StoreError> for PickerError {
}
pub enum PickerOutcome {
Picked(SegmentId),
/// User picked a session; resume at its leaf segment.
Picked {
session_id: SessionId,
segment_id: SegmentId,
},
Cancelled,
}
/// One row in the picker view. Rendered from the session log so the
/// user can recognise their session at a glance without parsing UUIDs.
/// One row in the picker view. Rendered from the leaf segment of a
/// Session so the user can recognise their conversation at a glance
/// without parsing UUIDs.
struct Row {
id: SegmentId,
session_id: SessionId,
leaf_segment_id: SegmentId,
/// Last user / assistant snippet, or a `[corrupt]` placeholder.
preview: String,
/// `Some(pod_name)` when a live Pod currently holds an allocation
/// for this session in `pods.json`. Picking such a row launches
/// `pod --session <UUID>` which will fail with `SegmentConflict` —
/// the badge warns the user up-front.
/// for this session's leaf segment in `pods.json`. Picking such a
/// row launches `pod --session <UUID>` which will fail with
/// `SegmentConflict` — the badge warns the user up-front.
live_pod: Option<String>,
}
pub async fn run() -> Result<PickerOutcome, PickerError> {
let store = open_default_store()?;
let ids = store.list_segments()?;
if ids.is_empty() {
let sessions = store.list_sessions()?;
if sessions.is_empty() {
return Err(PickerError::NoSessions);
}
let mut rows: Vec<Row> = Vec::with_capacity(MAX_ROWS);
for id in ids.into_iter().take(MAX_ROWS) {
let preview = build_preview(&store, id);
for session_id in sessions.into_iter().take(MAX_ROWS) {
let Some(leaf_segment_id) = store
.list_segments(session_id)?
.into_iter()
.next()
else {
continue;
};
let preview = build_preview(&store, session_id, leaf_segment_id);
// Best-effort live check. A pods.json I/O hiccup downgrades
// the row to "no badge" rather than killing the picker — the
// user still gets to see the listing.
let live_pod = lookup_segment(id).ok().flatten().map(|info| info.pod_name);
let live_pod = lookup_segment(leaf_segment_id)
.ok()
.flatten()
.map(|info| info.pod_name);
rows.push(Row {
id,
session_id,
leaf_segment_id,
preview,
live_pod,
});
@@ -115,7 +134,11 @@ pub async fn run() -> Result<PickerOutcome, PickerError> {
}
Some(Action::Submit) => {
close_viewport(&mut terminal)?;
return Ok(PickerOutcome::Picked(rows[selected].id));
let row = &rows[selected];
return Ok(PickerOutcome::Picked {
session_id: row.session_id,
segment_id: row.leaf_segment_id,
});
}
Some(Action::Cancel) => {
close_viewport(&mut terminal)?;
@@ -158,8 +181,8 @@ fn open_default_store() -> Result<FsStore, PickerError> {
Ok(FsStore::new(&dir)?)
}
fn build_preview(store: &FsStore, id: SegmentId) -> String {
match store.read_all(id) {
fn build_preview(store: &FsStore, session_id: SessionId, segment_id: SegmentId) -> String {
match store.read_all(session_id, segment_id) {
Ok(entries) => last_message_preview(&entries).unwrap_or_else(|| "[empty]".to_string()),
Err(_) => "[corrupt]".to_string(),
}
@@ -300,7 +323,7 @@ fn row_line(row: &Row, selected: bool) -> Line<'_> {
};
let mut spans = vec![
Span::raw(marker),
Span::styled(short_segment(row.id), id_style),
Span::styled(short_segment(row.session_id), id_style),
Span::raw(" "),
];
if let Some(ref pod_name) = row.live_pod {
@@ -313,7 +336,7 @@ fn row_line(row: &Row, selected: bool) -> Line<'_> {
Line::from(spans)
}
fn short_segment(id: SegmentId) -> String {
fn short_segment(id: SessionId) -> String {
let s = id.to_string();
s.chars().take(8).collect()
}
+1 -1
View File
@@ -329,7 +329,7 @@ async fn load_resume_scope(segment_id: SegmentId) -> Result<ScopeConfig, SpawnEr
)
})?;
let store = session_store::FsStore::new(&store_dir)?;
let state = session_store::restore(&store, segment_id)?;
let state = session_store::restore_by_segment(&store, segment_id)?;
let snapshot = state
.pod_scope
.ok_or(SpawnError::MissingResumeScope { segment_id })?;