feat: resume pods by name
This commit is contained in:
parent
d3b78234c2
commit
12a4ba5edf
|
|
@ -34,6 +34,9 @@ pub struct SpawnConfig {
|
||||||
/// `Some(id)` のとき `--session <id>` を付与し、当該セッションから
|
/// `Some(id)` のとき `--session <id>` を付与し、当該セッションから
|
||||||
/// resume させる。
|
/// resume させる。
|
||||||
pub resume_from: Option<Uuid>,
|
pub resume_from: Option<Uuid>,
|
||||||
|
/// true のとき `--pod <pod_name>` を付与し、pod 側で name-keyed state
|
||||||
|
/// があれば resume、なければ同名の新規 Pod として起動させる。
|
||||||
|
pub resume_by_pod_name: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct SpawnReady {
|
pub struct SpawnReady {
|
||||||
|
|
@ -111,6 +114,9 @@ where
|
||||||
.stdout(Stdio::null())
|
.stdout(Stdio::null())
|
||||||
.stderr(Stdio::from(stderr_file))
|
.stderr(Stdio::from(stderr_file))
|
||||||
.process_group(0);
|
.process_group(0);
|
||||||
|
if config.resume_by_pod_name {
|
||||||
|
command.arg("--pod").arg(&config.pod_name);
|
||||||
|
}
|
||||||
if let Some(id) = config.resume_from {
|
if let Some(id) = config.resume_from {
|
||||||
command.arg("--session").arg(id.to_string());
|
command.arg("--session").arg(id.to_string());
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,9 @@ use std::path::{Path, PathBuf};
|
||||||
use std::process::ExitCode;
|
use std::process::ExitCode;
|
||||||
|
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use manifest::{PodManifest, paths};
|
use manifest::{PodManifest, PodManifestConfig, paths};
|
||||||
use pod::{Pod, PodController, PodFactory, PromptLoader};
|
use pod::{Pod, PodController, PodFactory, PromptLoader};
|
||||||
use session_store::{FsStore, SegmentId, Store};
|
use session_store::{FsStore, PodMetadataStore, SegmentId, Store};
|
||||||
|
|
||||||
const USER_MANIFEST_ENV: &str = "INSOMNIA_USER_MANIFEST";
|
const USER_MANIFEST_ENV: &str = "INSOMNIA_USER_MANIFEST";
|
||||||
|
|
||||||
|
|
@ -47,12 +47,18 @@ struct Cli {
|
||||||
#[arg(long, value_name = "PATH", requires = "adopt")]
|
#[arg(long, value_name = "PATH", requires = "adopt")]
|
||||||
callback: Option<PathBuf>,
|
callback: Option<PathBuf>,
|
||||||
|
|
||||||
|
/// Resume or create a Pod by name. If name-keyed Pod state exists,
|
||||||
|
/// the active session/segment recorded there is restored; otherwise a
|
||||||
|
/// fresh top-level Pod is created with this name.
|
||||||
|
#[arg(long, value_name = "NAME", conflicts_with_all = ["session", "adopt"])]
|
||||||
|
pod: Option<String>,
|
||||||
|
|
||||||
/// Restore a Pod from an existing session. The Pod re-uses the
|
/// Restore a Pod from an existing session. The Pod re-uses the
|
||||||
/// given session id and appends new turns to the same jsonl;
|
/// given session id and appends new turns to the same jsonl;
|
||||||
/// concurrent writers are prevented by the pod-registry.
|
/// concurrent writers are prevented by the pod-registry.
|
||||||
/// Mutually exclusive with `--adopt` (spawned children always start
|
/// Mutually exclusive with `--adopt` (spawned children always start
|
||||||
/// fresh).
|
/// fresh).
|
||||||
#[arg(long, value_name = "UUID", conflicts_with = "adopt")]
|
#[arg(long, value_name = "UUID", conflicts_with_all = ["adopt", "pod"])]
|
||||||
session: Option<SegmentId>,
|
session: Option<SegmentId>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -72,7 +78,7 @@ fn resolve_manifest_with_user_manifest_env(
|
||||||
"--manifest cannot be used when {USER_MANIFEST_ENV} is set"
|
"--manifest cannot be used when {USER_MANIFEST_ENV} is set"
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
return load_single_manifest(path);
|
return load_single_manifest(path, cli.pod.as_deref());
|
||||||
}
|
}
|
||||||
|
|
||||||
let factory = build_factory_with_user_manifest_path(cli, user_manifest)?;
|
let factory = build_factory_with_user_manifest_path(cli, user_manifest)?;
|
||||||
|
|
@ -91,14 +97,45 @@ fn user_manifest_path_from_env(value: Option<OsString>) -> Option<PathBuf> {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn load_single_manifest(path: &Path) -> Result<(PodManifest, PromptLoader), String> {
|
fn load_single_manifest(
|
||||||
|
path: &Path,
|
||||||
|
pod_name_override: Option<&str>,
|
||||||
|
) -> Result<(PodManifest, PromptLoader), String> {
|
||||||
let toml = std::fs::read_to_string(path)
|
let toml = std::fs::read_to_string(path)
|
||||||
.map_err(|e| format!("failed to read manifest {}: {e}", path.display()))?;
|
.map_err(|e| format!("failed to read manifest {}: {e}", path.display()))?;
|
||||||
let manifest = PodManifest::from_toml(&toml)
|
let manifest = match pod_name_override {
|
||||||
.map_err(|e| format!("failed to parse manifest {}: {e}", path.display()))?;
|
Some(pod_name) => match PodManifest::from_toml(&toml) {
|
||||||
|
Ok(mut manifest) => {
|
||||||
|
manifest.pod.name = pod_name.to_string();
|
||||||
|
manifest
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
let base = PodManifestConfig::from_toml(&toml)
|
||||||
|
.map_err(|e| format!("failed to parse manifest {}: {e}", path.display()))?;
|
||||||
|
let overlay = PodManifestConfig::from_toml(&pod_name_overlay_toml(pod_name))
|
||||||
|
.expect("pod name overlay TOML is generated");
|
||||||
|
PodManifest::try_from(base.merge(overlay)).map_err(|e| {
|
||||||
|
format!(
|
||||||
|
"failed to resolve manifest {} with --pod: {e}",
|
||||||
|
path.display()
|
||||||
|
)
|
||||||
|
})?
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None => PodManifest::from_toml(&toml)
|
||||||
|
.map_err(|e| format!("failed to parse manifest {}: {e}", path.display()))?,
|
||||||
|
};
|
||||||
Ok((manifest, PromptLoader::builtins_only()))
|
Ok((manifest, PromptLoader::builtins_only()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn pod_name_overlay_toml(pod_name: &str) -> String {
|
||||||
|
let mut pod = toml::value::Table::new();
|
||||||
|
pod.insert("name".into(), toml::Value::String(pod_name.to_string()));
|
||||||
|
let mut root = toml::value::Table::new();
|
||||||
|
root.insert("pod".into(), toml::Value::Table(pod));
|
||||||
|
toml::to_string(&toml::Value::Table(root)).expect("pod name overlay serialisation cannot fail")
|
||||||
|
}
|
||||||
|
|
||||||
fn build_factory_with_user_manifest_path(
|
fn build_factory_with_user_manifest_path(
|
||||||
cli: &Cli,
|
cli: &Cli,
|
||||||
user_manifest: Option<PathBuf>,
|
user_manifest: Option<PathBuf>,
|
||||||
|
|
@ -129,6 +166,12 @@ fn build_factory_with_user_manifest_path(
|
||||||
.map_err(|e| format!("failed to parse overlay TOML: {e}"))?;
|
.map_err(|e| format!("failed to parse overlay TOML: {e}"))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(pod_name) = cli.pod.as_deref() {
|
||||||
|
factory = factory
|
||||||
|
.with_overlay_toml(&pod_name_overlay_toml(pod_name))
|
||||||
|
.map_err(|e| format!("failed to apply --pod overlay: {e}"))?;
|
||||||
|
}
|
||||||
|
|
||||||
Ok(factory)
|
Ok(factory)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -136,7 +179,7 @@ fn build_factory_with_user_manifest_path(
|
||||||
async fn main() -> ExitCode {
|
async fn main() -> ExitCode {
|
||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
|
|
||||||
let (manifest, loader) = match resolve_manifest(&cli) {
|
let (mut manifest, loader) = match resolve_manifest(&cli) {
|
||||||
Ok(pair) => pair,
|
Ok(pair) => pair,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("error: {e}");
|
eprintln!("error: {e}");
|
||||||
|
|
@ -214,6 +257,30 @@ async fn main() -> ExitCode {
|
||||||
return ExitCode::FAILURE;
|
return ExitCode::FAILURE;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if let Some(pod_name) = cli.pod.as_deref() {
|
||||||
|
manifest.pod.name = pod_name.to_string();
|
||||||
|
match store.read_by_name(pod_name) {
|
||||||
|
Ok(Some(_)) => {
|
||||||
|
match Pod::restore_from_pod_metadata(pod_name, manifest, store, loader).await {
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("error: failed to restore pod {pod_name}: {e}");
|
||||||
|
return ExitCode::FAILURE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(None) => match Pod::from_manifest(manifest, store, loader).await {
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("error: failed to create pod {pod_name}: {e}");
|
||||||
|
return ExitCode::FAILURE;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("error: failed to read pod state for {pod_name}: {e}");
|
||||||
|
return ExitCode::FAILURE;
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
match Pod::from_manifest(manifest, store, loader).await {
|
match Pod::from_manifest(manifest, store, loader).await {
|
||||||
Ok(p) => p,
|
Ok(p) => p,
|
||||||
|
|
@ -369,6 +436,56 @@ permission = "write"
|
||||||
assert_eq!(manifest.pod.name, "from-env");
|
assert_eq!(manifest.pod.name, "from-env");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pod_flag_conflicts_with_session() {
|
||||||
|
let segment_id = session_store::new_segment_id();
|
||||||
|
let segment_id = segment_id.to_string();
|
||||||
|
let err =
|
||||||
|
Cli::try_parse_from(["pod", "--pod", "agent", "--session", &segment_id]).unwrap_err();
|
||||||
|
assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pod_flag_sets_requested_name_after_manifest_resolution() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let manifest = tmp.path().join("manifest.toml");
|
||||||
|
write(&manifest, &manifest_toml("from-file", tmp.path()));
|
||||||
|
let cli = Cli::try_parse_from([
|
||||||
|
"pod",
|
||||||
|
"--manifest",
|
||||||
|
manifest.to_str().unwrap(),
|
||||||
|
"--pod",
|
||||||
|
"from-flag",
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let (manifest, _loader) = resolve_manifest_with_user_manifest_env(&cli, None).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(manifest.pod.name, "from-flag");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pod_flag_supplies_missing_name_for_single_manifest() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let manifest = tmp.path().join("manifest.toml");
|
||||||
|
write(
|
||||||
|
&manifest,
|
||||||
|
&manifest_toml("unused", tmp.path()).replace("name = \"unused\"\n", ""),
|
||||||
|
);
|
||||||
|
let cli = Cli::try_parse_from([
|
||||||
|
"pod",
|
||||||
|
"--manifest",
|
||||||
|
manifest.to_str().unwrap(),
|
||||||
|
"--pod",
|
||||||
|
"from-flag",
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let (manifest, _loader) = resolve_manifest_with_user_manifest_env(&cli, None).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(manifest.pod.name, "from-flag");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn manifest_mode_loads_single_file_with_minimal_prompt_loader() {
|
fn manifest_mode_loads_single_file_with_minimal_prompt_loader() {
|
||||||
let tmp = TempDir::new().unwrap();
|
let tmp = TempDir::new().unwrap();
|
||||||
|
|
|
||||||
|
|
@ -45,12 +45,20 @@ fn resolve_socket(pod_name: &str, override_path: Option<PathBuf>) -> PathBuf {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
enum Mode {
|
enum Mode {
|
||||||
Spawn,
|
Spawn,
|
||||||
Attach {
|
Attach {
|
||||||
pod_name: String,
|
pod_name: String,
|
||||||
socket_override: Option<PathBuf>,
|
socket_override: Option<PathBuf>,
|
||||||
},
|
},
|
||||||
|
/// `tui --pod <name>`: attach to a live Pod by name if possible;
|
||||||
|
/// otherwise launch `pod --pod <name>` so the pod process resumes from
|
||||||
|
/// name-keyed state or creates a fresh same-name Pod.
|
||||||
|
PodName {
|
||||||
|
pod_name: String,
|
||||||
|
socket_override: Option<PathBuf>,
|
||||||
|
},
|
||||||
/// `tui -r` / `tui --resume`: open the session picker first, then
|
/// `tui -r` / `tui --resume`: open the session picker first, then
|
||||||
/// run the same name dialog as Spawn but in resume mode.
|
/// run the same name dialog as Spawn but in resume mode.
|
||||||
Resume,
|
Resume,
|
||||||
|
|
@ -59,8 +67,9 @@ enum Mode {
|
||||||
ResumeWithSession(SegmentId),
|
ResumeWithSession(SegmentId),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
enum ParseError {
|
enum ParseError {
|
||||||
Conflict,
|
Conflict(&'static str),
|
||||||
InvalidSession(String),
|
InvalidSession(String),
|
||||||
MissingValue(&'static str),
|
MissingValue(&'static str),
|
||||||
}
|
}
|
||||||
|
|
@ -68,7 +77,7 @@ enum ParseError {
|
||||||
impl std::fmt::Display for ParseError {
|
impl std::fmt::Display for ParseError {
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
match self {
|
match self {
|
||||||
Self::Conflict => write!(f, "--resume and --session are mutually exclusive"),
|
Self::Conflict(message) => write!(f, "{message}"),
|
||||||
Self::InvalidSession(s) => write!(f, "invalid --session UUID: {s}"),
|
Self::InvalidSession(s) => write!(f, "invalid --session UUID: {s}"),
|
||||||
Self::MissingValue(flag) => write!(f, "{flag} requires a value"),
|
Self::MissingValue(flag) => write!(f, "{flag} requires a value"),
|
||||||
}
|
}
|
||||||
|
|
@ -76,9 +85,18 @@ impl std::fmt::Display for ParseError {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_args() -> Result<Mode, ParseError> {
|
fn parse_args() -> Result<Mode, ParseError> {
|
||||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
parse_args_from(std::env::args().skip(1))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_args_from<I, S>(args: I) -> Result<Mode, ParseError>
|
||||||
|
where
|
||||||
|
I: IntoIterator<Item = S>,
|
||||||
|
S: Into<String>,
|
||||||
|
{
|
||||||
|
let args: Vec<String> = args.into_iter().map(Into::into).collect();
|
||||||
let mut resume = false;
|
let mut resume = false;
|
||||||
let mut session: Option<SegmentId> = None;
|
let mut session: Option<SegmentId> = None;
|
||||||
|
let mut pod: Option<String> = None;
|
||||||
let mut socket_override: Option<PathBuf> = None;
|
let mut socket_override: Option<PathBuf> = None;
|
||||||
let mut positional: Option<String> = None;
|
let mut positional: Option<String> = None;
|
||||||
|
|
||||||
|
|
@ -99,6 +117,11 @@ fn parse_args() -> Result<Mode, ParseError> {
|
||||||
);
|
);
|
||||||
i += 2;
|
i += 2;
|
||||||
}
|
}
|
||||||
|
"--pod" => {
|
||||||
|
let raw = args.get(i + 1).ok_or(ParseError::MissingValue("--pod"))?;
|
||||||
|
pod = Some(raw.clone());
|
||||||
|
i += 2;
|
||||||
|
}
|
||||||
"--socket" => {
|
"--socket" => {
|
||||||
let raw = args
|
let raw = args
|
||||||
.get(i + 1)
|
.get(i + 1)
|
||||||
|
|
@ -119,9 +142,27 @@ fn parse_args() -> Result<Mode, ParseError> {
|
||||||
}
|
}
|
||||||
|
|
||||||
if resume && session.is_some() {
|
if resume && session.is_some() {
|
||||||
return Err(ParseError::Conflict);
|
return Err(ParseError::Conflict(
|
||||||
|
"--resume and --session are mutually exclusive",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if pod.is_some() && session.is_some() {
|
||||||
|
return Err(ParseError::Conflict(
|
||||||
|
"--pod and --session are mutually exclusive",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if pod.is_some() && resume {
|
||||||
|
return Err(ParseError::Conflict(
|
||||||
|
"--pod and --resume are mutually exclusive",
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(pod_name) = pod {
|
||||||
|
return Ok(Mode::PodName {
|
||||||
|
pod_name,
|
||||||
|
socket_override,
|
||||||
|
});
|
||||||
|
}
|
||||||
if let Some(id) = session {
|
if let Some(id) = session {
|
||||||
return Ok(Mode::ResumeWithSession(id));
|
return Ok(Mode::ResumeWithSession(id));
|
||||||
}
|
}
|
||||||
|
|
@ -163,6 +204,10 @@ async fn main() -> ExitCode {
|
||||||
pod_name,
|
pod_name,
|
||||||
socket_override,
|
socket_override,
|
||||||
} => run_attach(pod_name, socket_override).await,
|
} => run_attach(pod_name, socket_override).await,
|
||||||
|
Mode::PodName {
|
||||||
|
pod_name,
|
||||||
|
socket_override,
|
||||||
|
} => run_pod_name(pod_name, socket_override).await,
|
||||||
Mode::Resume => run_resume().await,
|
Mode::Resume => run_resume().await,
|
||||||
Mode::ResumeWithSession(id) => run_spawn(Some(id)).await,
|
Mode::ResumeWithSession(id) => run_spawn(Some(id)).await,
|
||||||
};
|
};
|
||||||
|
|
@ -205,6 +250,37 @@ async fn run_attach(
|
||||||
run(&mut terminal, pod_name, &socket_path).await
|
run(&mut terminal, pod_name, &socket_path).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn run_pod_name(
|
||||||
|
pod_name: String,
|
||||||
|
socket_override: Option<PathBuf>,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let socket_path = resolve_socket(&pod_name, socket_override);
|
||||||
|
if let Ok(client) = PodClient::connect(&socket_path).await {
|
||||||
|
let mut terminal = enter_fullscreen()?;
|
||||||
|
let mut app = App::new(pod_name);
|
||||||
|
app.connected = true;
|
||||||
|
return run_loop(&mut terminal, &mut app, client).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let ready = match spawn::run_pod_name(pod_name).await? {
|
||||||
|
SpawnOutcome::Ready(r) => r,
|
||||||
|
SpawnOutcome::Cancelled => return Ok(()),
|
||||||
|
};
|
||||||
|
let SpawnReady {
|
||||||
|
pod_name,
|
||||||
|
socket_path,
|
||||||
|
} = ready;
|
||||||
|
|
||||||
|
let mut terminal = enter_fullscreen()?;
|
||||||
|
let result = run(&mut terminal, pod_name, &socket_path).await;
|
||||||
|
let _ = execute!(
|
||||||
|
terminal.backend_mut(),
|
||||||
|
DisableMouseCapture,
|
||||||
|
LeaveAlternateScreen
|
||||||
|
);
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
async fn run_resume() -> Result<(), Box<dyn std::error::Error>> {
|
async fn run_resume() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
// Phase 1: pick a session in its own inline viewport, dropping the
|
// Phase 1: pick a session in its own inline viewport, dropping the
|
||||||
// viewport before the name dialog opens so each phase gets fresh
|
// viewport before the name dialog opens so each phase gets fresh
|
||||||
|
|
@ -612,3 +688,32 @@ fn handle_pause_or_quit(app: &mut App) -> Option<Method> {
|
||||||
app.push_error("Press Ctrl-C again within 3 s to exit the TUI (the Pod keeps running).");
|
app.push_error("Press Ctrl-C again within 3 s to exit the TUI (the Pod keeps running).");
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_pod_name_mode() {
|
||||||
|
match parse_args_from(["--pod", "agent", "--socket", "/tmp/agent.sock"]).unwrap() {
|
||||||
|
Mode::PodName {
|
||||||
|
pod_name,
|
||||||
|
socket_override,
|
||||||
|
} => {
|
||||||
|
assert_eq!(pod_name, "agent");
|
||||||
|
assert_eq!(socket_override, Some(PathBuf::from("/tmp/agent.sock")));
|
||||||
|
}
|
||||||
|
_ => panic!("expected PodName mode"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_rejects_pod_and_session() {
|
||||||
|
let segment_id = session_store::new_segment_id().to_string();
|
||||||
|
let err = parse_args_from(["--pod", "agent", "--session", &segment_id]).unwrap_err();
|
||||||
|
assert_eq!(
|
||||||
|
err.to_string(),
|
||||||
|
"--pod and --session are mutually exclusive"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -90,56 +90,18 @@ type InlineTerminal = Terminal<CrosstermBackend<io::Stdout>>;
|
||||||
/// behaviour); `Some(id)` swaps the dialog into "Resume Pod" mode and
|
/// behaviour); `Some(id)` swaps the dialog into "Resume Pod" mode and
|
||||||
/// passes `--session <id>` to the spawned `pod` child.
|
/// passes `--session <id>` to the spawned `pod` child.
|
||||||
pub async fn run(resume_from: Option<SegmentId>) -> Result<SpawnOutcome, SpawnError> {
|
pub async fn run(resume_from: Option<SegmentId>) -> Result<SpawnOutcome, SpawnError> {
|
||||||
let cwd = std::env::current_dir().map_err(SpawnError::Io)?;
|
let defaults = load_spawn_defaults()?;
|
||||||
|
|
||||||
// Run the same merge pod itself uses, then read what's missing
|
|
||||||
// off the result. We only look at `scope.allow` here — `pod.name`
|
|
||||||
// is intentionally an instance-level identifier and is always
|
|
||||||
// taken from the dialog regardless of what (if anything) a layer
|
|
||||||
// declared.
|
|
||||||
let user_layer = user_manifest_path()
|
|
||||||
.filter(|p| p.is_file())
|
|
||||||
.and_then(|p| load_layer(&p).ok());
|
|
||||||
let project_layer = find_project_manifest_from(&cwd).and_then(|p| load_layer(&p).ok());
|
|
||||||
|
|
||||||
let mut cascade = PodManifestConfig::builtin_defaults();
|
|
||||||
for layer in [user_layer.as_ref(), project_layer.as_ref()]
|
|
||||||
.into_iter()
|
|
||||||
.flatten()
|
|
||||||
{
|
|
||||||
cascade = cascade.merge(layer.clone());
|
|
||||||
}
|
|
||||||
let cascade_has_scope = !cascade.scope.allow.is_empty();
|
|
||||||
|
|
||||||
let scope_origin = match (
|
|
||||||
project_layer
|
|
||||||
.as_ref()
|
|
||||||
.is_some_and(|l| !l.scope.allow.is_empty()),
|
|
||||||
user_layer
|
|
||||||
.as_ref()
|
|
||||||
.is_some_and(|l| !l.scope.allow.is_empty()),
|
|
||||||
) {
|
|
||||||
(true, _) => ScopeOrigin::FromProject,
|
|
||||||
(false, true) => ScopeOrigin::FromUser,
|
|
||||||
(false, false) => ScopeOrigin::CwdDefault,
|
|
||||||
};
|
|
||||||
|
|
||||||
let default_name = cwd
|
|
||||||
.file_name()
|
|
||||||
.and_then(|s| s.to_str())
|
|
||||||
.map(sanitise_default_name)
|
|
||||||
.filter(|s| !s.is_empty())
|
|
||||||
.unwrap_or_else(|| "pod".to_string());
|
|
||||||
|
|
||||||
let mut form = Form {
|
let mut form = Form {
|
||||||
cwd: cwd.clone(),
|
cwd: defaults.cwd.clone(),
|
||||||
cascade_has_scope,
|
cascade_has_scope: defaults.cascade_has_scope,
|
||||||
scope_origin,
|
scope_origin: defaults.scope_origin,
|
||||||
name_cursor: default_name.chars().count(),
|
name_cursor: defaults.default_name.chars().count(),
|
||||||
name: default_name,
|
name: defaults.default_name,
|
||||||
message: None,
|
message: None,
|
||||||
editing: true,
|
editing: true,
|
||||||
resume_from,
|
resume_from,
|
||||||
|
resume_by_pod_name: false,
|
||||||
resume_scope: None,
|
resume_scope: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -206,6 +168,101 @@ pub async fn run(resume_from: Option<SegmentId>) -> Result<SpawnOutcome, SpawnEr
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Launch `pod --pod <name>` without opening the name dialog. The child Pod
|
||||||
|
/// resolves persisted Pod metadata if present, or creates a fresh same-name Pod
|
||||||
|
/// with the usual TUI cwd-scope fallback.
|
||||||
|
pub async fn run_pod_name(pod_name: String) -> Result<SpawnOutcome, SpawnError> {
|
||||||
|
let defaults = load_spawn_defaults()?;
|
||||||
|
let mut form = Form {
|
||||||
|
cwd: defaults.cwd,
|
||||||
|
cascade_has_scope: defaults.cascade_has_scope,
|
||||||
|
scope_origin: defaults.scope_origin,
|
||||||
|
name_cursor: pod_name.chars().count(),
|
||||||
|
name: pod_name,
|
||||||
|
message: Some(("resuming pod...".to_string(), MessageKind::Progress)),
|
||||||
|
editing: false,
|
||||||
|
resume_from: None,
|
||||||
|
resume_by_pod_name: true,
|
||||||
|
resume_scope: None,
|
||||||
|
};
|
||||||
|
let overlay_toml = build_overlay_toml(&form);
|
||||||
|
let mut terminal = make_inline_terminal()?;
|
||||||
|
terminal.draw(|f| draw_form(f, &form))?;
|
||||||
|
|
||||||
|
match wait_for_ready(&mut terminal, &mut form, &overlay_toml).await {
|
||||||
|
Ok(ready) => {
|
||||||
|
form.message = Some((
|
||||||
|
format!("ready: {} attaching...", ready.pod_name),
|
||||||
|
MessageKind::Ok,
|
||||||
|
));
|
||||||
|
terminal.draw(|f| draw_form(f, &form))?;
|
||||||
|
drop(terminal);
|
||||||
|
Ok(SpawnOutcome::Ready(ready))
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
form.message = Some((e.to_string(), MessageKind::Error));
|
||||||
|
let _ = terminal.draw(|f| draw_form(f, &form));
|
||||||
|
drop(terminal);
|
||||||
|
Err(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SpawnDefaults {
|
||||||
|
cwd: PathBuf,
|
||||||
|
cascade_has_scope: bool,
|
||||||
|
scope_origin: ScopeOrigin,
|
||||||
|
default_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_spawn_defaults() -> Result<SpawnDefaults, SpawnError> {
|
||||||
|
let cwd = std::env::current_dir().map_err(SpawnError::Io)?;
|
||||||
|
|
||||||
|
// Run the same merge pod itself uses, then read what's missing off the
|
||||||
|
// result. We only look at `scope.allow` here — `pod.name` is an
|
||||||
|
// instance-level identifier and is supplied by the dialog or `--pod`.
|
||||||
|
let user_layer = user_manifest_path()
|
||||||
|
.filter(|p| p.is_file())
|
||||||
|
.and_then(|p| load_layer(&p).ok());
|
||||||
|
let project_layer = find_project_manifest_from(&cwd).and_then(|p| load_layer(&p).ok());
|
||||||
|
|
||||||
|
let mut cascade = PodManifestConfig::builtin_defaults();
|
||||||
|
for layer in [user_layer.as_ref(), project_layer.as_ref()]
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
{
|
||||||
|
cascade = cascade.merge(layer.clone());
|
||||||
|
}
|
||||||
|
let cascade_has_scope = !cascade.scope.allow.is_empty();
|
||||||
|
|
||||||
|
let scope_origin = match (
|
||||||
|
project_layer
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|l| !l.scope.allow.is_empty()),
|
||||||
|
user_layer
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|l| !l.scope.allow.is_empty()),
|
||||||
|
) {
|
||||||
|
(true, _) => ScopeOrigin::FromProject,
|
||||||
|
(false, true) => ScopeOrigin::FromUser,
|
||||||
|
(false, false) => ScopeOrigin::CwdDefault,
|
||||||
|
};
|
||||||
|
|
||||||
|
let default_name = cwd
|
||||||
|
.file_name()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.map(sanitise_default_name)
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.unwrap_or_else(|| "pod".to_string());
|
||||||
|
|
||||||
|
Ok(SpawnDefaults {
|
||||||
|
cwd,
|
||||||
|
cascade_has_scope,
|
||||||
|
scope_origin,
|
||||||
|
default_name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn make_inline_terminal() -> io::Result<InlineTerminal> {
|
fn make_inline_terminal() -> io::Result<InlineTerminal> {
|
||||||
let backend = CrosstermBackend::new(io::stdout());
|
let backend = CrosstermBackend::new(io::stdout());
|
||||||
Terminal::with_options(
|
Terminal::with_options(
|
||||||
|
|
@ -279,6 +336,7 @@ async fn wait_for_ready(
|
||||||
overlay_toml: overlay_toml.to_string(),
|
overlay_toml: overlay_toml.to_string(),
|
||||||
cwd,
|
cwd,
|
||||||
resume_from: form.resume_from,
|
resume_from: form.resume_from,
|
||||||
|
resume_by_pod_name: form.resume_by_pod_name,
|
||||||
};
|
};
|
||||||
let ready = spawn_pod(config, |line| {
|
let ready = spawn_pod(config, |line| {
|
||||||
form.message = Some((line.to_string(), MessageKind::Progress));
|
form.message = Some((line.to_string(), MessageKind::Progress));
|
||||||
|
|
@ -377,6 +435,9 @@ struct Form {
|
||||||
/// child pod is launched with `--session <id>` so it restores
|
/// child pod is launched with `--session <id>` so it restores
|
||||||
/// from `id` and appends to the same session log.
|
/// from `id` and appends to the same session log.
|
||||||
resume_from: Option<SegmentId>,
|
resume_from: Option<SegmentId>,
|
||||||
|
/// When true, launch the child with `--pod <name>` so the pod process
|
||||||
|
/// resolves name-keyed state before falling back to fresh creation.
|
||||||
|
resume_by_pod_name: bool,
|
||||||
/// Scope snapshot recovered from the source session log. Set only for
|
/// Scope snapshot recovered from the source session log. Set only for
|
||||||
/// resume runs, and serialized into the overlay instead of cwd-default
|
/// resume runs, and serialized into the overlay instead of cwd-default
|
||||||
/// scope so resume does not silently broaden access.
|
/// scope so resume does not silently broaden access.
|
||||||
|
|
@ -556,6 +617,7 @@ mod tests {
|
||||||
message: None,
|
message: None,
|
||||||
editing: true,
|
editing: true,
|
||||||
resume_from: None,
|
resume_from: None,
|
||||||
|
resume_by_pod_name: false,
|
||||||
resume_scope: None,
|
resume_scope: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user