From b60279689262ed1850af8361625acabb69d9fcd3 Mon Sep 17 00:00:00 2001 From: Yuto Nishida Date: Sun, 11 Oct 2026 19:56:07 -0700 Subject: [PATCH] silverwood: move recorded session run under session --- experimental/silverwood/CLI_SPEC.jsonnet | 29 ++++----- .../crates/silverwood-cli/src/main.rs | 64 +++++++++---------- .../crates/silverwood-cli/tests/cli.rs | 16 ++--- .../crates/silverwood-cli/tests/e2e.rs | 15 ++--- .../crates/silverwood-core/src/forest.rs | 2 +- .../crates/silverwood-core/src/workstream.rs | 2 +- 6 files changed, 54 insertions(+), 74 deletions(-) diff --git a/experimental/silverwood/CLI_SPEC.jsonnet b/experimental/silverwood/CLI_SPEC.jsonnet index 364d113..305579f 100644 --- a/experimental/silverwood/CLI_SPEC.jsonnet +++ b/experimental/silverwood/CLI_SPEC.jsonnet @@ -267,6 +267,15 @@ ], }, }, + { + name: "run", + intentHint: "Run a saved session.", + generatedDescriptionHint: "Run a recorded session in its workstream checkout using its saved kind and settings.", + positionals: [ + { intentHint: "The workstream id.", domain: { semanticName: "ID", validation: "silverwoodFn:workstreamId" } }, + { intentHint: "The session id.", domain: { semanticName: "SESSION_ID" } }, + ], + }, { name: "ls", intentHint: "List sessions on a workstream.", @@ -344,26 +353,10 @@ { name: "session-schema", intentHint: "Describe session creation kinds for frontends." }, { name: "spawn", - intentHint: "Low-level helper for launching a session's environment.", - generatedDescriptionHint: "Run a session in the intended working directory, or inspect its shell plan with --json.", + intentHint: "Launch a session kind directly without looking up a recorded session.", + generatedDescriptionHint: "Run a session kind in the chosen working directory, or inspect its shell plan with --json.", subcommands: { choices: [ - { - // TODO: move this to be under the `session` subcommand - name: "from-id", - generatedDescriptionHint: "Run a recorded session in its workstream checkout.", - options: [ - { - longName: "workstream", - generatedDescriptionHint: "Workstream containing the session.", - domain: { semanticName: "WORKSTREAM" }, - required: true, - }, - ], - positionals: [ - { generatedDescriptionHint: "The session id.", domain: { semanticName: "SESSION_ID" } }, - ], - }, { name: "plain-shell", intentHint: "Launch a plain interactive shell.", diff --git a/experimental/silverwood/crates/silverwood-cli/src/main.rs b/experimental/silverwood/crates/silverwood-cli/src/main.rs index 1dd8a86..5dda870 100644 --- a/experimental/silverwood/crates/silverwood-cli/src/main.rs +++ b/experimental/silverwood/crates/silverwood-cli/src/main.rs @@ -111,12 +111,9 @@ enum Command { /// without hardcoding the kind list. Human output lists one line per kind. SessionSchema, - /// Run a session: exec its shell in a checkout, replacing this process (so on success - /// it never returns; the caller's PTY then tracks the session directly). `from-id` - /// looks up a durable session and runs it the way it records itself; the per-kind - /// subcommands run a kind directly in the current directory (or - /// `--override-working-directory`). The environment is a scrubbed login env either way. - /// Intent: launch a recorded or direct session in the intended working directory; + /// Run a session kind directly in the current directory (or + /// `--override-working-directory`), replacing this process on success. + /// Intent: launch a direct session in the intended working directory; /// `--json` reveals the plan. Spawn { #[command(subcommand)] @@ -128,21 +125,10 @@ enum Command { RegisterCodexSession, } -/// `silverwood spawn ` — how to run a session. `from-id` resolves a durable -/// session object; the per-kind leaves run a kind directly and take +/// `silverwood spawn ` — run a kind directly with /// `--override-working-directory`. The kind-selecting shape mirrors the `SessionKind`s. #[derive(Subcommand)] enum SpawnCommand { - /// Run the durable session `` on `--workstream ` the way it records - /// itself (its kind, and first-run vs resume, are decided by silverwood). Runs in the - /// workstream's checkout, so it takes no `--override-working-directory`. - FromId { - /// The session id to run (from `silverwood session ls`). - session_id: String, - /// The workstream the session is attached to (from `silverwood ls`). - #[arg(long)] - workstream: String, - }, /// A plain interactive login shell. PlainShell { #[command(flatten)] @@ -201,7 +187,7 @@ impl ClaudeRunArg { } /// The working directory for a cwd-derived spawn kind: `--override-working-directory` if -/// given, else this process's own cwd. (Not accepted by `from-id`, which uses the checkout.) +/// given, else this process's own cwd. Recorded sessions use their checkout instead. #[derive(clap::Args)] struct WorkingDir { /// Working directory to run in (default: this process's own cwd). @@ -267,6 +253,13 @@ enum SessionCommand { /// Create (record) a session of a given agent kind. #[command(subcommand)] Create(SessionCreate), + /// Run a recorded session in its workstream checkout using its saved kind and settings. + Run { + /// The workstream id the session belongs to (from `silverwood ls`). + id: String, + /// The session id to run (from `silverwood session ls`). + session_id: String, + }, /// List the agent sessions recorded on a workstream. Ls { /// The workstream id whose sessions to list (from `silverwood ls`). @@ -760,7 +753,7 @@ fn run(cli: Cli) -> CliResult { Command::Kv(cmd) => run_kv(&forest, json, cmd), Command::Session(cmd) => run_session(&forest, json, cmd), - Command::Spawn { what } => run_spawn(&forest, json, what), + Command::Spawn { what } => run_spawn(json, what), Command::RegisterCodexSession => register_codex_session(&forest), @@ -916,6 +909,7 @@ fn run_session(forest: &Forest, json: bool, cmd: SessionCommand) -> CliResult { | SessionCommand::Create(SessionCreate::PlainShell { id, .. }) | SessionCommand::Create(SessionCreate::ClaudeCodeNoninteractiveshell { id, .. }) | SessionCommand::Create(SessionCreate::DiskSpace { id, .. }) + | SessionCommand::Run { id, .. } | SessionCommand::Ls { id } | SessionCommand::Rename { id, .. } | SessionCommand::Rm { id, .. } @@ -925,6 +919,16 @@ fn run_session(forest: &Forest, json: bool, cmd: SessionCommand) -> CliResult { }; match cmd { + SessionCommand::Run { session_id, .. } => { + let seed = spawn_seed()?; + let plan = forest.spawn_plan_from_session( + id, + &session_id, + &seed, + &resolve_claude_config_dir()?, + )?; + return exec_plan(json, plan); + } SessionCommand::Create(SessionCreate::ClaudeCode { session_id, name, .. }) => { @@ -1063,23 +1067,13 @@ fn shell_quote(value: &str) -> String { format!("'{}'", value.replace('\'', "'\\''")) } -/// Build the [`ShellPlan`] for the chosen `spawn` subcommand and `exec` it (see -/// [`exec_plan`]). `from-id` asks core to resolve a durable session into a plan (core owns -/// *how* each kind runs); the per-kind subcommands call the matching plan builder with a -/// resolved working directory. Reading the seed/config-dir from the environment is frontend -/// policy (like [`resolve_forest_dir`]), so it stays here. -fn run_spawn(forest: &Forest, json: bool, what: SpawnCommand) -> CliResult { +/// Build the [`ShellPlan`] for the chosen direct `spawn` subcommand and `exec` it (see +/// [`exec_plan`]). Each subcommand calls its matching plan builder with a resolved +/// working directory. Reading the seed from the environment is frontend policy +/// (like [`resolve_forest_dir`]), so it stays here. +fn run_spawn(json: bool, what: SpawnCommand) -> CliResult { let seed = spawn_seed()?; let plan = match what { - SpawnCommand::FromId { - session_id, - workstream, - } => forest.spawn_plan_from_session( - parse_id(&workstream)?, - &session_id, - &seed, - &resolve_claude_config_dir()?, - )?, SpawnCommand::PlainShell { wd } => plain_shell_plan(&wd.resolve()?, &seed), SpawnCommand::DiskSpace { wd } => disk_space_plan(&wd.resolve()?, &seed), SpawnCommand::ClaudeCode { run } => { diff --git a/experimental/silverwood/crates/silverwood-cli/tests/cli.rs b/experimental/silverwood/crates/silverwood-cli/tests/cli.rs index 1c74db5..c0ec68d 100644 --- a/experimental/silverwood/crates/silverwood-cli/tests/cli.rs +++ b/experimental/silverwood/crates/silverwood-cli/tests/cli.rs @@ -453,25 +453,21 @@ fn remove_rejects_bad_and_absent_ids() { } #[test] -fn spawn_from_id_rejects_bad_and_absent_ids() { +fn session_run_rejects_bad_and_absent_ids() { let dir = forest(); // Bad workstream id → parse error, before any forest work or exec. - let stderr = fails( - &dir, - &["spawn", "from-id", "sess-1", "--workstream", "not-a-uuid"], - ); + let stderr = fails(&dir, &["session", "run", "not-a-uuid", "sess-1"]); assert!(stderr.contains("invalid workstream id"), "got: {stderr}"); - // Well-formed but absent id → not-found (there is no workstream to spawn from). + // Well-formed but absent id → not-found (there is no workstream to run from). fails( &dir, &[ - "spawn", - "from-id", - "sess-1", - "--workstream", + "session", + "run", "uuidv7_01999999-0000-7000-8000-000000000000", + "sess-1", ], ); } diff --git a/experimental/silverwood/crates/silverwood-cli/tests/e2e.rs b/experimental/silverwood/crates/silverwood-cli/tests/e2e.rs index 770a998..ddf2bfa 100644 --- a/experimental/silverwood/crates/silverwood-cli/tests/e2e.rs +++ b/experimental/silverwood/crates/silverwood-cli/tests/e2e.rs @@ -346,7 +346,7 @@ fn new_direnv_unsafe_mode_is_ready() { assert!(Path::new(loc).join(".jj").is_dir(), ".jj missing"); } -/// `spawn from-id --json` resolves a durable session into a plan: it reads the session's +/// `session run --json` resolves a durable session into a plan: it reads the session's /// kind and runs it in the workstream's checkout, deciding first-run vs resume from whether /// a Claude transcript exists under `CLAUDE_CONFIG_DIR`. Needs a real (ready) checkout — /// hence ignored. (The pure kind→plan builders are unit-tested in `silverwood-core`; @@ -354,7 +354,7 @@ fn new_direnv_unsafe_mode_is_ready() { /// is direnv-blind, and explicit `direnv exec` lives on `claude-code-noninteractiveshell`.) #[test] #[ignore = "network + jj; run via `cargo test -- --ignored`"] -fn spawn_from_id_resolves_each_session_kind() { +fn session_run_resolves_each_session_kind() { let dir = forest(); // An empty CLAUDE_CONFIG_DIR ⇒ no transcripts ⇒ claude sessions resolve to first-run. let claude_cfg = forest(); @@ -382,7 +382,7 @@ fn spawn_from_id_resolves_each_session_kind() { let plan = json_env( &dir, &[("CLAUDE_CONFIG_DIR", cfg)], - &["--json", "spawn", "from-id", "cc-1", "--workstream", id], + &["--json", "session", "run", id, "cc-1"], ); assert_eq!(plan["args"][0], "-l"); assert_eq!(plan["args"][1], "-i"); @@ -409,7 +409,7 @@ fn spawn_from_id_resolves_each_session_kind() { let plan = json_env( &dir, &[("CLAUDE_CONFIG_DIR", cfg)], - &["--json", "spawn", "from-id", "ni-1", "--workstream", id], + &["--json", "session", "run", id, "ni-1"], ); assert_eq!(plan["program"], "direnv"); assert_eq!( @@ -419,10 +419,7 @@ fn spawn_from_id_resolves_each_session_kind() { // plain-shell: a login shell in the checkout. ok(&dir, &["session", "create", "plain-shell", id, "sh-1"]); - let plan = json( - &dir, - &["--json", "spawn", "from-id", "sh-1", "--workstream", id], - ); + let plan = json(&dir, &["--json", "session", "run", id, "sh-1"]); assert_eq!(plan["args"], serde_json::json!(["-l"])); assert_eq!(plan["cwd"], cwd); @@ -434,7 +431,7 @@ fn spawn_from_id_resolves_each_session_kind() { let plan = json_env( &dir, &[("CLAUDE_CONFIG_DIR", cfg)], - &["--json", "spawn", "from-id", "cc-2", "--workstream", id], + &["--json", "session", "run", id, "cc-2"], ); assert!(plan["args"][3] .as_str() diff --git a/experimental/silverwood/crates/silverwood-core/src/forest.rs b/experimental/silverwood/crates/silverwood-core/src/forest.rs index 62994c2..b94f1d1 100644 --- a/experimental/silverwood/crates/silverwood-core/src/forest.rs +++ b/experimental/silverwood/crates/silverwood-core/src/forest.rs @@ -503,7 +503,7 @@ impl Forest { }) } - /// Build the [`ShellPlan`] for running a durable session (`silverwood spawn from-id`). + /// Build the [`ShellPlan`] for running a durable session (`silverwood session run`). /// A session records *how to run itself*: this reads its [`SessionKind`] and materializes /// the matching command in the workstream's directory (the private `spawn_cwd` helper), /// so a frontend diff --git a/experimental/silverwood/crates/silverwood-core/src/workstream.rs b/experimental/silverwood/crates/silverwood-core/src/workstream.rs index d6bcc83..b9423e3 100644 --- a/experimental/silverwood/crates/silverwood-core/src/workstream.rs +++ b/experimental/silverwood/crates/silverwood-core/src/workstream.rs @@ -127,7 +127,7 @@ pub struct SessionLock { /// optional [`SessionLock`] (a Claude Code session can be resumed by only one client /// at a time); a kind that already guarantees single-user access — such as /// `plain-shell`, whose every reopen is an independent fresh login shell — carries no -/// lock. A session records *how to run itself*, so `silverwood spawn from-id` can +/// lock. A session records *how to run itself*, so `silverwood session run` can /// materialize it without the frontend re-deriving the command. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "kebab-case")] -- 2.51.2