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:
@@ -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(
|
||||
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 })?;
|
||||
|
||||
Reference in New Issue
Block a user