diff --git a/crates/mcp-config/src/lib.rs b/crates/mcp-config/src/lib.rs index f118b11..ffbbd95 100644 --- a/crates/mcp-config/src/lib.rs +++ b/crates/mcp-config/src/lib.rs @@ -13,6 +13,12 @@ //! `--settings`. The CLI starts every enabled server in the file, and a //! shared working directory holds one per account (#103). //! +//! What the app knows about the CLI itself is here too, for want of anywhere +//! better: which flag carries a session id (`SESSION_ID_PLACEHOLDER`), and +//! where the conversation under that id is kept (`transcript_path`). Both are +//! per-CLI answers, and gathering them in one crate is what makes a second CLI +//! a second answer rather than a search through the app (#131, decision 3). +//! //! This crate holds no tauri: it writes into the user's own project directory, //! which is the part of Pullcept that most needs test coverage, and a test //! binary linking the tauri tree does not load on the GNU target. @@ -218,6 +224,65 @@ pub fn substitute_session_id(args: &[String], session_id: &str) -> Vec { .collect() } +/// How long a directory slug may be before the CLI stops spelling it out. +/// +/// Past this the CLI cuts the slug here and appends a hash of the path, and the +/// hash is a private detail of its own — reproducing it would be copying an +/// implementation rather than a layout. `transcript_path` answers `None` for +/// those instead of guessing (see there). +const SLUG_LIMIT: usize = 200; + +/// Where the CLI keeps the transcript of one session. +/// +/// **The one place Pullcept depends on where a CLI stores its conversations** +/// (#131, decision 3). The layout is Claude Code's: +/// `/.claude/projects//.jsonl`. +/// Another CLI keeps them somewhere else entirely — `codex` writes +/// `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl` — so a second CLI is a second +/// answer from this function, and not a second reader grown somewhere else in +/// the app. The dependency is accepted rather than avoided (Master 判断): +/// asking the file system whether the conversation is there is steadier than +/// matching the CLI's refusal text, which is the thing #127 already refuses to +/// read. +/// +/// The slug folds every character that is not an ASCII letter or digit into a +/// `-`, the drive's colon and the separators alike: `C:\Users\smile\Code` +/// becomes `C--Users-smile-Code`. Folded per UTF-16 code unit rather than per +/// character, because the rule being copied is a JavaScript regular expression +/// and that is the unit it steps in — a character outside the basic plane is +/// two dashes there and would be one here. +/// +/// `None` when the slug would pass `SLUG_LIMIT`: the CLI shortens those and the +/// app cannot name the file. It is not "the transcript is missing" — the caller +/// keeps whatever it would have done without this answer, because a guess that +/// named the wrong file would read as a conversation that is gone. +pub fn transcript_path(home: &Path, cwd: &Path, session_id: &str) -> Option { + Some( + home.join(".claude") + .join("projects") + .join(project_slug(&cwd.to_string_lossy())?) + .join(format!("{session_id}.jsonl")), + ) +} + +/// The working directory as it names a directory under `projects/`. +/// +/// The result is ASCII by construction, so its byte length is the length the +/// CLI measures against the limit. +fn project_slug(cwd: &str) -> Option { + let mut slug = String::new(); + for ch in cwd.chars() { + if ch.is_ascii_alphanumeric() { + slug.push(ch); + } else { + for _ in 0..ch.len_utf16() { + slug.push('-'); + } + } + } + (slug.len() <= SLUG_LIMIT).then_some(slug) +} + /// The character an account speaks as, or `None` when it declares none. /// /// Blank is the same state as absent. The field is a text input on the screen, @@ -1116,4 +1181,51 @@ mod tests { ))); } + /// Both directories were read off a running install (2026-08-29), and they + /// are what fixes the rule: the drive's colon and each separator are one + /// dash apiece, so the leading `C:` leaves two. + #[test] + fn the_transcript_of_a_session_sits_under_the_slug_of_its_directory() { + let home = PathBuf::from(r"C:\Users\smile"); + assert_eq!( + transcript_path(&home, Path::new(r"C:\Users\smile\Code"), "0f5a-uuid"), + Some(home.join(".claude").join("projects").join("C--Users-smile-Code").join("0f5a-uuid.jsonl")) + ); + assert_eq!( + transcript_path(&home, Path::new(r"C:\Users\smile\Claude"), "0f5a-uuid"), + Some(home.join(".claude").join("projects").join("C--Users-smile-Claude").join("0f5a-uuid.jsonl")) + ); + } + + /// The fold is not of separators. A dot, a space and a character with no + /// ASCII spelling all go the same way, which is why the rule is written as + /// what survives rather than as what is replaced. The last of them takes + /// two dashes and not one: it is one character here and two units in the + /// language the rule is written in. + #[test] + fn every_character_that_is_not_a_letter_or_a_digit_folds_into_a_dash() { + let home = PathBuf::from(r"C:\Users\smile"); + assert_eq!( + transcript_path(&home, Path::new(r"C:\Users\smile\my code.v2\部屋\😀"), "id"), + Some( + home.join(".claude") + .join("projects") + .join("C--Users-smile-my-code-v2------") + .join("id.jsonl") + ) + ); + } + + /// A path the CLI would shorten names no file this app can predict, and the + /// answer says so rather than pointing at one that is not there. + #[test] + fn a_directory_too_long_to_spell_out_names_no_transcript() { + let long = format!("C:\\{}", "d".repeat(SLUG_LIMIT)); + assert_eq!( + transcript_path(Path::new("C:\\home"), Path::new(&long), "id"), + None + ); + let edge = format!("C:\\{}", "d".repeat(SLUG_LIMIT - 3)); + assert!(transcript_path(Path::new("C:\\home"), Path::new(&edge), "id").is_some()); + } } diff --git a/docs/0-requirements.md b/docs/0-requirements.md index f9ca88d..a192f63 100644 --- a/docs/0-requirements.md +++ b/docs/0-requirements.md @@ -275,7 +275,7 @@ logs/{部屋名}/{トピック}.jsonl そのトピックの発言、追記の **新規セッションへ渡す id は、同じ置換子を起動オプションへ書くことで配る。** 欄を増やさない。`claude` では `--session-id {session_id}` に当たるが、どのフラグが id を運ぶかは CLI ごとの問いであり、再開コマンドが答えているのと同じ問いである。アプリはフラグを持たず、書かれた場所へ差し込む。**書かなければ id を配らない**——それは resume を持たない CLI が恒常的に置かれる状態であり、失敗ではない。 -**判定は「このトピックがこのアカウントのセッションを持つか」で行う。** 「アカウントが resume を持つか」ではない。記録と再開コマンドの両方が揃ったときだけ resume であり、どちらかが欠ければ新規起動である。起動チェック(互換しないフラグ、`--settings` の二重宣言)は解決後の行に対して走る——アカウントの行を見て別の行を spawn する形にしない。 +**判定は「このトピックがこのアカウントのセッションを持つか」で行う。** 「アカウントが resume を持つか」ではない。記録と再開コマンドが揃い、かつその会話が実在するときだけ resume であり(三つめは #131、下記「再開の線は、その会話が実在するときだけ選ぶ」)、どれかが欠ければ新規起動である。起動チェック(互換しないフラグ、`--settings` の二重宣言)は解決後の行に対して走る——アカウントの行を見て別の行を spawn する形にしない。 **再開できない席があってもトピックは開く。** 復帰できる席は復帰させ、できない席は新規起動として着席する。読み出しがあるため、復帰できなかった席も自分で辿れる。トピックが開かない形は取らない。resume だったかどうかは画面の状況行で言う——黙って同じ言葉で済ませると、戻ってきたのかどうかを人が判別できない。 @@ -287,7 +287,19 @@ logs/{部屋名}/{トピック}.jsonl そのトピックの発言、追記の **外したことは状態行で述べる。** 握り潰すと、履歴が繋がっていないまま話が続く。上記「復帰の手段は二段構えである」と同じ形であり、resume だったかどうかを状況行で言うのと同じ理由である。**自動では起動し直さない(AI 判断)。** 記録が外れているため、次に押せば通常起動で立つ。こちらで再試行を回すと失敗の繰り返しをアプリが回す形になり、`model-loop-safety` が避ける形に当たる。押すかどうかは人が決める。 -**再開の線に適用範囲を持たせる形は、ここでは扱わない。** 再開コマンドはもともとスリープ復帰への手当てとして入ったものであり(#108、#84)、`resolve_launch` はその由来を知らない。どの状況のための線かをアカウント側が言えるようにする形は設定の概念を増やす変更であり、この復帰とは別の判断である(#127、別 issue)。 +**再開の線は、その会話が実在するときだけ選ぶ(#131、決定1)。** 揃うべきは三つである——記録された id が在ること、アカウントが再開の線を持つこと、**その id の会話がファイルとして在ること**。前の二つはこのアプリ自身が書いた記録であり、記録した時点では会話ができたことを言っていない(上記「戻れなかった再開先は、そのトピックから外す」)。三つめだけが CLI の側の記録であり、それを起動の前に見る。**在るものから選べば、無いものへ戻ろうとする状態はそもそも作れない。** 記録した id を無条件に渡して失敗から立て直す形の逆であり、立て直す経路(#127)が通る場面はこれでほとんど残らなくなる。 + +**実在しなければ、その場で記録を外す(決定2)。** 失敗を待たない。待てば次に押したときも同じ線へ入るため、#127 が一度だけ払う失敗を毎回払うことになる。外したあとは通常起動として立ち、外したことは状態行で述べる——上記「外したことは状態行で述べる」と同じ理由であり、同じ状況行が言う。 + +**CLI の保存場所への依存を受け入れる(Master 判断、決定3)。** 見るのはファイルの有無だけであり、CLI の出力も終了コードの値も読まない(上記「判定に CLI の出力は読まない」)。文字列の一致より安定である代わりに、保存の形は CLI ごとに異なる:`claude` は `~/.claude/projects/<作業ディレクトリのスラッグ>/<セッション id>.jsonl`、`codex` は `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl` に置く。複数の CLI を迎える設計とは噛み合わないが、現状は `claude` 依存で構わないという判断である。**依存は `crates/mcp-config/` の `transcript_path` 一つに閉じる。** CLI が増えるときに差し替える場所を一つにするためであり、「どのフラグが id を運ぶか」(上記「新規セッションへ渡す id は…」)と同じ形の問いを同じ場所へ集める。スラッグは英数字でない文字を一つずつ `-` へ畳んだものであり、`C:\Users\smile\Code` は `C--Users-smile-Code` になる(2026-08-29、実機の `~/.claude/projects/` にある二例から確定)。 + +**中身は読まない(制約)。** 壊れた記録を弾く話はここではしない。問いは「その会話が在るか」であって「その会話が読めるか」ではない。 + +**#127 の復帰は残す(決定4)。** 事前に見るのはファイルの有無だけであるため、会話が在るのに CLI が戻ることを拒む場合は残る。再開が成って部屋に一度も現れないまま終えた場合の取り消しは、その守りとして生きている。消して後から足し直すことはしない。 + +**二つの場面は、二つの別の仕組みである(#131)。** CLI が生きている側——スリープからの復帰——は繋ぎ直しであり、席の引き取りが担う(#84、#115 決定4C)。PTY が生きているため `--resume` は要らない。CLI が死んでいる側——アプリの再起動——が読み直しであり、resume が担うのはこちらだけである。**二つの場合ではなく、二つの仕組みが一つの設定欄に相乗りしている。** 上の実在判定が resume の側だけに置かれているのはそのためであり、繋ぎ直しの経路はこの判定を通らない。 + +**再開の線に適用範囲を持たせる形は、ここでは扱わない。** 再開コマンドはもともとスリープ復帰への手当てとして入ったものであり(#108、#84)、`resolve_launch` はその由来を知らない。どの状況のための線かをアカウント側が言えるようにする形は設定の概念を増やす変更であり、この復帰とは別の判断である(#127、#128、別 issue)。上の切り分けはその判断の材料であって、欄そのものを動かすものではない。 **トピックへ入ると床は空になり、席は入口へ戻る。** 前のトピックの発言は、入るトピックの床ではない。残したままにすると、開き直したトピックで最初に発言した参加者が、前のトピックの保持窓ごと「見落とし」として返される——実際にはその全部を読んでいる参加者に対して、である。席そのものは残る。トピックの切り替えは誰かを部屋から出すことではなく、どこで話しているかが変わることである。 @@ -350,7 +362,7 @@ liplus-desktop の `stream_parser.rs` および `spawn_stream_pty` / `spawn_stre - 部屋のログ(`logs/main/{トピック}.jsonl` への追記、床の判定と同じロック内での書き込み、保存する五欄、`hue` と `own` を持ち込まない写像、追記の失敗の画面への通知、トピックを指定する読み出しコマンド、参加者への再配信をしないこと) - トピック(手動の区切り、起動時の新規トピックと遅延生成、索引とディレクトリの照合、最初の発言からの命名とその場での変更、既存 `logs/main.jsonl` の移行、切り替え時の床の初期化) - トピックの削除(行の `❌`、確認のダイアログ、ファイルと索引の項目の両方の削除、そのトピックで走っているセッションの終了、開いているトピックを消したときの新しいトピックへの移動、題の無い行も消せること) -- トピックの再開(アカウントの再開コマンド、`{session_id}` の置換、トピックへのセッション id の記録、再開できない席の新規着席、名簿に現れないまま終えた再開の記録の取り消しと状態行での通知) +- トピックの再開(アカウントの再開コマンド、`{session_id}` の置換、トピックへのセッション id の記録、再開の前に会話が実在するかを見ること、実在しない記録のその場での取り消し、再開できない席の新規着席、名簿に現れないまま終えた再開の記録の取り消し、いずれも状態行での通知) - 参加者からの読み出し(サイドカーの `read_room_history`、`history` / `history_result` フレーム、pull のみ、ページと `before` の巻き戻し) - トピックの一覧(会話面の左の列、新しい順、先頭に固定の「新規」と索引に無い現在のトピックでのその選択状態、選択による部屋への読み戻し、参加者パネルと対の寸法、日付を含む時刻、名前から導く色) - 参加者モデル(統一 `post` フレーム、発言者以外の全参加者への配送、接続同一性による自分の発言の抑止と名簿の同一性、人間を含む名簿) @@ -485,6 +497,7 @@ liplus-desktop の `stream_parser.rs` および `spawn_stream_pty` / `spawn_stre - 既存 `logs/main.jsonl` の移行の実機確認。移動と索引への採録は実装済みで、対象は現時点で 2 行だが、実機では未実行である。 - トピックの再開の実機確認。`--session-id` で配った id が実際に `claude --resume` で戻るところは未計測である。CLI の選択肢の存在は 2026-08-27 に確認しているが(#115 の前提)、往復そのものは辿っていない。同じ id を二度 `--session-id` で渡した場合の CLI の挙動も未計測であり、この実装はその経路へ入らない——記録が在るときは、新規起動側でも新しい id を配り直す。 - 戻れなかった再開先を外すところの実機確認(#127、上記「トピック」)。再開の線で起動して名簿に現れないまま終えた回で記録が外れること、状態行がそう述べること、次に押すと通常起動で立つことは実装済みで、CI の型検査・Rust のコンパイル・`crates/topic-index` のテストは通っているが、実機での操作は未確認である。この経路を実機で作るには、確認プロンプトで止まった起動を id が記録された状態で終える必要がある——#123 の実機確認がそれを起こした回であり、狙って再現する手順としては未確認である。 +- 会話が実在するかを起動の前に見るところの実機確認(#131、上記「トピック」)。スラッグの規則は 2026-08-29 に実機の `~/.claude/projects/` から確定し、`crates/mcp-config/` のテストが持っているが、記録の在るトピックを開き直して実際に再開が選ばれること、記録だけが残った組で通常起動へ落ちて状態行がそう述べることは、実機では未確認である。前者は上記「トピックの再開の実機確認」と同じ往復であり、そちらが未計測である以上こちらも未計測である。 - `read_room_history` を実際のセッションが呼ぶところの実機確認。サイドカーのラウンドトリップテストは通っているが、エージェントが必要な場面で自分から引くかどうかは未計測である。押し付けないという判断がそのまま「引かれない」に落ちる可能性は残っており、そこは `instructions` の書き方の問題として観測してから判断する。 - トピックを切り替えたときに、席を持っている参加者がそれをどう受け取るかの実機確認。床は空になり席は入口へ戻るが(上記「トピック」)、走っているセッション自身は自分の文脈をそのまま持ったままである。 - 複数の部屋。ログのパスは部屋名を位置として持つが(`logs/{部屋名}/`、現在は `main` に固定)、部屋そのものは一つである。 @@ -973,7 +986,7 @@ slug は読みやすさのためだけにあり、ASCII 英数字と `-` に落 ## テストの配置 -`.mcp.json` への登録と起動フラグの検査は `crates/mcp-config/`、床の判定と刻印は `crates/room-floor/`、トピックの保存(索引とディレクトリの照合、削除、題の生成、一行の解析)は `crates/topic-index/` という、いずれも tauri 非依存の crate に置く。 +`.mcp.json` への登録と起動フラグの検査、および会話の記録の在り処(`transcript_path`、上記「トピック」の決定3)は `crates/mcp-config/`、床の判定と刻印は `crates/room-floor/`、トピックの保存(索引とディレクトリの照合、削除、題の生成、一行の解析)は `crates/topic-index/` という、いずれも tauri 非依存の crate に置く。 理由は依存の正しさと、テストが実行できることの両方である。これらのロジックが tauri を必要とする理由はそもそも無い。加えて `src-tauri` 側に置くと、テストバイナリが tauri の依存ツリー全体をリンクするため、GNU ターゲットでは `STATUS_ENTRYPOINT_NOT_FOUND`(`0xc0000139`)でプロセスが起動せず、アサーションが一度も実行されない。これはローカル環境固有ではなく CI でも再現する(run 32431917979)。特定の依存クレートまでは切り分けていない。 @@ -993,7 +1006,7 @@ CI が実行するもの: | `npm run sidecar:check` | サイドカーの型検査 | | `npm run sidecar:test` | サイドカーの往復ハーネス | | `cargo check --target x86_64-pc-windows-gnu` | アプリのコンパイル | -| `cargo test`(`crates/mcp-config`) | `.mcp.json` マージ保全、起動フラグ検査、起動しないサーバの名指し | +| `cargo test`(`crates/mcp-config`) | `.mcp.json` マージ保全、起動フラグ検査、起動しないサーバの名指し、会話の記録の在り処(実機で確認した二例、英数字でない文字の畳み方、CLI が名前を切り詰める長さ) | | `cargo test`(`crates/room-floor`) | 床の判定(未読による拒否、自分の発言の除外、解決できない `last_seen`、席の位置、同時発話の順序付け、拒否が運ぶ宣言色) | | `cargo test`(`crates/topic-index`) | トピックの保存(索引に無いファイルの採録、削除がファイルと項目の両方を取ること、項目だけを消した場合の復活、他のトピックを乱さないこと、ファイルの無い項目の削除、一ファイル名でない id の拒否、題の生成、千切れた行の読み飛ばしと計数、セッション id の取り消しが項目と他アカウントを残すことと id 一致でのみ効くこと) | @@ -1067,7 +1080,8 @@ CI が実行するもの: | 読み戻した行が宣言色を持たない(#115) | ログは `hue` を持たないため、トピックを開き直すと過去の発言は名前から導いた色で出る。同じ名前を名乗った二人は一色になり、この画面自身の過去の発言もアクセントでは出ない。誰も宣言していない色を保存しないことの代償である | | セッション id の置換子を起動オプションへ書く(#115) | 「新しいセッションを始める」側に専用の欄が無く、`{session_id}` を自分で書く必要がある。どのフラグが id を運ぶかは CLI ごとの問いであり、アプリがフラグを持てば `claude` 以外で外れる | | 索引をディレクトリと照合して建て直す(#115) | 遅延生成の安全性がこの照合に乗る。#115 の時点では `AppHandle` 依存のため `cargo test` の外に在ったが、#119 が `crates/topic-index/` へ出した(上記「テストの配置」) | -| 死んだセッション id を次の再開の失敗で外す(#127) | 記録の位置は spawn の直後のままであり、会話ができた瞬間をアプリは知らない。会話の無い id は一度は書かれ、外れるのはその id で再開して失敗した後である——一回は失敗する。判定を名簿の到着で行うため、再開そのものは成った上でサイドカーが部屋へ繋がらないまま終えたセッションも同じ扱いになる。そちらは戻り道を失うが、読み出し(上記「復帰の手段は二段構えである」)は残る | +| 死んだセッション id を次の再開の失敗で外す(#127) | 記録の位置は spawn の直後のままであり、会話ができた瞬間をアプリは知らない。会話の無い id は一度は書かれる。外れるのは #131 の事前判定が入って以降は起動の前であり、この行が言う「一回は失敗する」代償は、会話が在るのに CLI が拒む場合だけに縮んだ。判定を名簿の到着で行うため、再開そのものは成った上でサイドカーが部屋へ繋がらないまま終えたセッションも同じ扱いになる。そちらは戻り道を失うが、読み出し(上記「復帰の手段は二段構えである」)は残る | +| 再開の可否を CLI の保存場所で見る(#131) | 判定が `claude` の保存の形に依存する。CLI が増えれば `crates/mcp-config/` の `transcript_path` を書き足すことになり、書き足すまでその CLI のセッションは常に「会話が無い」と読まれる。加えて作業ディレクトリの綴りが長い場合、CLI は保存先の名前を切り詰めて自分のハッシュを付ける——その名前はアプリには作れないため、判定を行わずに記録を残したまま再開の線へ入る。そこは #127 の復帰が受け止める | | 起動途中の席は削除から見えない(#119) | 席は PTY が立つ前に確保されるため(`Seat::Starting`)、その窓の中に在るセッションには止めるものがまだ無く、削除はそれを終えられない。トピックが消えた後にその起動が完了すれば、セッション id はどのトピックにも属さないまま残る。押す操作が二つ同時に要る幅であり、席のロックと索引のロックを起動をまたいで両方持つ形を避けるほうを採る | ## 位置づけ diff --git a/src-tauri/src/room_log.rs b/src-tauri/src/room_log.rs index 743560d..2b42092 100644 --- a/src-tauri/src/room_log.rs +++ b/src-tauri/src/room_log.rs @@ -421,6 +421,15 @@ pub fn record_session( /// back on its own, so the pair (account, topic) stays broken until the record /// goes. /// +/// Two callers reach it, from either side of the launch. The next launch is the +/// first: it looks for the conversation before choosing the resume line, and +/// drops the record where it finds nothing (`session::resolve_launch`, #131). +/// The screen is the second, and is what remains for a conversation that is on +/// disk and refused anyway — the resume ran and the room never saw the session +/// (#127). The first covers nearly everything the second used to, and the +/// second is kept rather than folded in, because what it observes is the CLI's +/// answer and not the file system's (#131, decision 4). +/// /// The id is named, so this undoes the record it was told about and not a later /// one; the match is the crate's (`TopicIndex::forget_session`). Answering /// false is a normal outcome and not a failure — see there. diff --git a/src-tauri/src/session.rs b/src-tauri/src/session.rs index 86a7a0f..734858a 100644 --- a/src-tauri/src/session.rs +++ b/src-tauri/src/session.rs @@ -15,13 +15,13 @@ use crate::room_log::{self, TopicRef}; use mcp_config::{ declared_character, declares_session_id, declares_settings, launch_args, other_room_servers, register_sidecar, reject_incompatible_flags, server_name_for, split_launch_options, - substitute_session_id, RoomRegistration, + substitute_session_id, transcript_path, RoomRegistration, }; use parking_lot::Mutex; use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use std::sync::Arc; -use tauri::AppHandle; +use tauri::{AppHandle, Manager}; /// The sidecar entry point and the runner that executes it. /// @@ -362,16 +362,65 @@ struct LaunchLine { /// needs — a resume that could not go back has to name the record it is /// dropping (#127). resumed_from: Option, + /// The session id this launch took off the topic on its way past, because + /// the conversation it named is not on disk (#131, decision 2). + /// + /// A resolved line and a dropped record are not alternatives: the drop is + /// why this line is the fresh one. Carried out so the screen can say it — + /// the record went without the person asking, and a repair nobody is told + /// about is a history that quietly stopped being continuous (#127). + dropped_resume: Option, +} + +/// Whether the conversation this id names can be found on disk. +/// +/// The check decision 1 adds to the two the resume already had (#131). Both of +/// those are records this app wrote; this one asks the file system, which is +/// the only party that knows whether the conversation was ever made. +/// +/// Answers true when the file is there — and also when this app cannot tell, +/// which is a home directory it could not resolve or a working directory the +/// CLI spells with a hash (`transcript_path`). Unknown falls on the side of +/// keeping the record: the launch then goes in on the resume line exactly as it +/// did before this check existed, and #127 still takes the record off if the +/// CLI turns it away. Guessing the other way would drop a record that was fine +/// on the strength of not having looked. +/// +/// Nothing is read out of the file. Whether a transcript is intact is a +/// question about its contents, and this one is about whether there is anything +/// there at all (#131, 制約). +fn transcript_found(app: &AppHandle, cwd: &Path, session_id: &str) -> bool { + let Ok(home) = app.path().home_dir() else { + return true; + }; + match transcript_path(&home, cwd, session_id) { + Some(path) => path.is_file(), + None => true, + } } /// Which of the account's two lines this launch is, and with which id. /// -/// Resume needs both halves: a session recorded for this account in this topic, -/// and a line that knows how to go back into one. Missing either, the launch is -/// a fresh one — the account then reads back what it needs through the room's -/// own pull instead, which is the second tier of the two-tier answer and the -/// reason a missing resume line is a degraded state rather than a failure -/// (#115, decision 4). +/// Resume needs three things, and the third is the one #131 adds: a session +/// recorded for this account in this topic, a line that knows how to go back +/// into one, and the conversation that id names still being on disk. Missing +/// any of them, the launch is a fresh one — the account then reads back what it +/// needs through the room's own pull instead, which is the second tier of the +/// two-tier answer and the reason a missing resume line is a degraded state +/// rather than a failure (#115, decision 4). +/// +/// The third is not the same kind of condition as the other two. They are read +/// off records this app keeps and cost nothing to be wrong about; this one is a +/// record the CLI keeps, and being wrong about it is what #127 had to repair +/// after the fact. Checking it here is what makes the state unbuildable rather +/// than recoverable: a line back into a conversation that is not there is never +/// chosen, so there is nothing to fall back from. +/// +/// **A record with no conversation behind it is dropped where it is found** +/// (#131, decision 2), rather than left for the failure to take off. Waiting +/// would mean the next press goes in on the same line again, which is the loop +/// #127 exists at the far end of. What is dropped is named on the way out, so +/// the screen can say it happened. /// /// A fresh launch mints an id whenever the account's options name the /// placeholder, including when the topic already holds one. The old id is @@ -381,6 +430,7 @@ fn resolve_launch( app: &AppHandle, account: &Account, topic: &TopicRef, + cwd: &Path, ) -> Result { let recorded = room_log::session_of(app, &topic.topic_id, &account.id); let resume = account @@ -389,29 +439,46 @@ fn resolve_launch( .map(str::trim) .filter(|line| !line.is_empty()); + let mut dropped_resume = None; if let (Some(session_id), Some(line)) = (recorded.as_deref(), resume) { - let mut parts = split_launch_options(line); - // The first token is the command, and a resume line whose first token - // is empty would spawn nothing under a name the person never chose. The - // reachable way in is a line of nothing but quotes, which splits into - // one empty token rather than into none. - let command = if parts.is_empty() { - String::new() - } else { - parts.remove(0) - }; - if command.is_empty() { - return Err(format!( - "Account \"{}\" has a resume command with no command in it. Write the whole line, command first.", - account.name.trim() - )); + if transcript_found(app, cwd, session_id) { + let mut parts = split_launch_options(line); + // The first token is the command, and a resume line whose first + // token is empty would spawn nothing under a name the person never + // chose. The reachable way in is a line of nothing but quotes, + // which splits into one empty token rather than into none. + let command = if parts.is_empty() { + String::new() + } else { + parts.remove(0) + }; + if command.is_empty() { + return Err(format!( + "Account \"{}\" has a resume command with no command in it. Write the whole line, command first.", + account.name.trim() + )); + } + return Ok(LaunchLine { + command, + args: substitute_session_id(&parts, session_id), + session_id: None, + resumed_from: Some(session_id.to_string()), + dropped_resume: None, + }); + } + + // The id is named, so what goes is this record and not one a relaunch + // wrote in between (`room_log::forget_session`). Answering false is + // that check having held, not a failure, and nothing is claimed in that + // case. A failure to write does not fail the launch either: the record + // stands and the next press comes back here, which is the same state + // this arrived in — said on the log's own surface, because a record + // that quietly stopped being kept still looks like one. + match room_log::forget_session(app, &topic.topic_id, &account.id, session_id) { + Ok(true) => dropped_resume = Some(session_id.to_string()), + Ok(false) => {} + Err(err) => room_log::report(app, err), } - return Ok(LaunchLine { - command, - args: substitute_session_id(&parts, session_id), - session_id: None, - resumed_from: Some(session_id.to_string()), - }); } if declares_session_id(&account.args) { @@ -421,6 +488,7 @@ fn resolve_launch( args: substitute_session_id(&account.args, &session_id), session_id: Some(session_id), resumed_from: None, + dropped_resume, }); } @@ -429,6 +497,7 @@ fn resolve_launch( args: account.args.clone(), session_id: None, resumed_from: None, + dropped_resume, }) } @@ -468,6 +537,14 @@ pub struct StartedSession { /// is a record with nothing behind it, and dropping that record names it /// (#127). pub resumed_from: Option, + /// The session id this launch took off the topic before starting, because + /// the conversation it named is not on disk, or `null` when it took none. + /// + /// Never set together with `resumed_from`: the drop is what made this the + /// fresh line. The screen says it for the same reason it says the one #127 + /// drops — the way back into a conversation went, and nobody asked for that + /// (#131, decision 2). + pub dropped_resume: Option, } /// Put one account into the room. @@ -523,28 +600,17 @@ pub fn start_session( // and a value carried through the screen could name a topic the room has // since left. let topic = room.topic(); - // Resume or fresh, decided here so every check below runs against the line - // that will actually be spawned. Checking the account's own options and - // then spawning the resume line would be checking the wrong line. - let launch_line = resolve_launch(&app, &account, &topic)?; - - if let Err(flag) = reject_incompatible_flags(&launch_line.args) { - return Err(format!( - "Account \"{name}\" passes {flag}, which stops channel pushes from arriving. \ - Remove it from the launch options." - )); - } - - let character = declared_character(account.character.as_deref()); - - let port = room - .port() - .ok_or_else(|| "The room socket is not listening yet.".to_string())?; - let room_url = format!("ws://127.0.0.1:{port}"); // No fallback to the app's own process directory. Under `tauri dev` that // is `src-tauri`, and a session silently launched there is a session the // person never chose and cannot see they got (#20). + // + // Resolved ahead of the line rather than beside the rest of the launch's + // materials, because the line is now resolved against it: the transcript of + // a session is filed under the directory it ran in, so there is no reading + // the topic's record without one. A directory that is missing or unset ends + // the launch here, which is also what keeps a record from being dropped on + // the strength of a directory nobody could have launched in (#131). let cwd = match account.cwd.as_deref().map(str::trim) { Some(dir) if !dir.is_empty() => PathBuf::from(dir), _ => { @@ -560,6 +626,25 @@ pub fn start_session( )); } + // Resume or fresh, decided here so every check below runs against the line + // that will actually be spawned. Checking the account's own options and + // then spawning the resume line would be checking the wrong line. + let launch_line = resolve_launch(&app, &account, &topic, &cwd)?; + + if let Err(flag) = reject_incompatible_flags(&launch_line.args) { + return Err(format!( + "Account \"{name}\" passes {flag}, which stops channel pushes from arriving. \ + Remove it from the launch options." + )); + } + + let character = declared_character(account.character.as_deref()); + + let port = room + .port() + .ok_or_else(|| "The room socket is not listening yet.".to_string())?; + let room_url = format!("ws://127.0.0.1:{port}"); + let server_name = server_name_for(&account.id); // Read before anything is written, and answering for the file as the // registration below will leave it. The CLI starts every enabled server in @@ -739,5 +824,6 @@ fn launch( started_at, topic_id: topic_id.to_string(), resumed_from: line.resumed_from.clone(), + dropped_resume: line.dropped_resume.clone(), }) } diff --git a/src/main.ts b/src/main.ts index e9f24ae..b5fba48 100644 --- a/src/main.ts +++ b/src/main.ts @@ -236,6 +236,13 @@ interface StartedSession { * resume that ends without the room ever seeing it has a record to drop, and * dropping it names it (#127). */ resumed_from: string | null; + /** The session id this launch took off the topic before starting, because + * the conversation it named is not on disk — or null when it took none. + * Never set together with `resumed_from`: the drop is what made this the + * fresh line. Said on the status line for the reason the one #127 drops is + * said there — the way back into a conversation went, and nobody asked for + * that (#131, decision 2). */ + dropped_resume: string | null; } /** @@ -3005,10 +3012,17 @@ async function startSession(account: Account): Promise { // Which of the two lines ran is said, because the person is the one who // can tell whether it mattered. A seat that came back fresh in a reopened // topic is a legitimate outcome and not a silent one (#115, decision 6). + // + // A fresh line the topic had a record for is a third thing to say. The + // record went before this launch was made, and a launch that reads as an + // ordinary one leaves the person to find out from the next 起動しました + // that the way back is gone (#131, decision 2). status( started.resumed_from !== null ? `${name} を再開しました。${started.mcp_config} に登録済み。` - : `${name} を起動しました。${started.mcp_config} に登録済み。`, + : started.dropped_resume !== null + ? `${name} を起動しました。戻る先の会話が見つからなかったため、このトピックの再開先は外しました。${started.mcp_config} に登録済み。` + : `${name} を起動しました。${started.mcp_config} に登録済み。`, ); await refreshSeats(); await followSession(view, started);