feat(runtime): run commands with real exit status

This commit is contained in:
kuddev committed 2026-08-19 21:08:25 +08:00
1 parent 8004f1399d
commit 63f3b79bee
7 files changed
+593 -4

No files matched your search

+12
View File
@@ -477,6 +477,18 @@ pub enum ControlCommand {
#[clap(long, default_value_t = 1)]
repeat: u16,
},
/// Run one shell command and return its real OSC 133 exit status.
Run {
#[clap(long)]
window: Option<u64>,
#[clap(long)]
pane: u64,
#[clap(long)]
command: String,
/// Submit the command and return its run id without waiting for completion.
#[clap(long)]
no_wait: bool,
},
/// Wait until a pane reaches a semantic task state.
Wait {
#[clap(long)]
+2
View File
@@ -219,6 +219,8 @@ pub struct NebulaPaneState {
pub touched: bool,
pub inline_images: Vec<NebulaInlineImage>,
pub command_started: Option<std::time::Instant>,
pub active_run: Option<crate::runtime_api::RuntimePaneRun>,
pub last_run: Option<crate::runtime_api::RuntimeRunOutcome>,
pub running_program: Option<String>,
/// hook 直报的 AI CLI 会话身份(claude `session_id` / codex `thread-id`)。
/// 与 `running_program` 同生命周期:133;D 命令收尾时一起清除,快照据此
+23
View File
@@ -611,6 +611,19 @@ impl Processor {
"bytes_sent": bytes_sent
}))
},
RuntimeCommand::Run { window_id, pane_id, command, .. } => {
let id = self.runtime_target_window(*window_id, Some(*pane_id))?;
let run_id = self
.windows
.get_mut(&id)
.expect("resolved runtime window exists")
.runtime_run(*pane_id, command.clone())?;
self.runtime_result(serde_json::json!({
"window_id": u64::from(id),
"pane_id": pane_id,
"run_id": run_id
}))
},
}
}
@@ -3247,6 +3260,11 @@ impl input::Processor<EventProxy, ActionContext<'_, Notifier, EventProxy>> {
self.ctx.nebula_state.pending_ssh_host =
crate::ssh::ssh_destination(&self.ctx.nebula_state.last_committed);
self.ctx.nebula_state.awaiting_input = false;
if let Some(run) = &mut self.ctx.nebula_state.active_run
&& run.phase == crate::runtime_api::RuntimeRunPhase::Submitted
{
run.phase = crate::runtime_api::RuntimeRunPhase::Started;
}
},
TerminalEvent::CommandDone { exit_code } => {
// Take (not just clear) the program: the toast below
@@ -3263,6 +3281,11 @@ impl input::Processor<EventProxy, ActionContext<'_, Notifier, EventProxy>> {
self.ctx.nebula_state.agent_status_rule = None;
let pending_ssh = self.ctx.nebula_state.pending_ssh_host.take();
self.ctx.nebula_state.awaiting_input = false;
if let Some(run) = self.ctx.nebula_state.active_run.take() {
self.ctx.nebula_state.last_run = Some(
crate::runtime_api::RuntimeRunOutcome::command_done(run, exit_code),
);
}
// 助手错误恢复(spec 001):Nebula 集成上报的退出码
// 走触发判定;裸 133;D(第三方集成)码为 None,静默。
if let Some(code) = exit_code {
+60 -1
View File
@@ -173,6 +173,8 @@ pub struct TerminalView {
/// `NEBULA|` 标题报 `running_program`,装了 OSC133 集成的 bash/zsh/nu 只
/// 发这一对语义标记——两条路都要能点亮侧栏的「运行中」。
command_running: bool,
active_run: Option<crate::runtime_api::RuntimePaneRun>,
last_run: Option<crate::runtime_api::RuntimeRunOutcome>,
/// Hook 事件驱动的 agent 回合状态(旧壳 `nebula_state.agent_status` 的
/// 边沿触发版)。`Unknown` = 本 pane 没有 agent 参与,spinner 完全由
/// `command_running`/`running_program` 决定。
@@ -617,6 +619,8 @@ impl TerminalView {
branch: String::new(),
running_program: None,
command_running: false,
active_run: None,
last_run: None,
agent_status: crate::ai_agents::AgentStatus::Unknown,
agent_status_source: crate::ai_agents::AgentStatusSource::Unknown,
agent_status_rule: None,
@@ -773,17 +777,26 @@ impl TerminalView {
self.agent_status_rule = None;
}
self.command_running = true;
if let Some(run) = &mut self.active_run
&& run.phase == crate::runtime_api::RuntimeRunPhase::Submitted
{
run.phase = crate::runtime_api::RuntimeRunPhase::Started;
}
cx.notify();
},
// 退出码先只用来结束「运行中」;失败命令的未读标记要等
// `SidebarActivity` 扩出未读态再接,现在记下来也没有呈现位置。
TermEvent::CommandDone { exit_code: _ } => {
TermEvent::CommandDone { exit_code } => {
// 旧壳同款收尾:CLI 退回提示符后,它不再是这个 pane 的前台
// 事实——hook 稍后若仍在跑会重新点亮(handle_ai_hook 覆写)。
if self.running_program.take().is_some() || self.ai_session.take().is_some() {
cx.emit(TerminalViewEvent::TitleChanged);
}
self.command_running = false;
if let Some(run) = self.active_run.take() {
self.last_run =
Some(crate::runtime_api::RuntimeRunOutcome::command_done(run, exit_code));
}
self.agent_status = crate::ai_agents::AgentStatus::Unknown;
self.agent_status_source = crate::ai_agents::AgentStatusSource::Unknown;
self.agent_status_rule = None;
@@ -1192,6 +1205,52 @@ impl TerminalView {
Ok(bytes_sent)
}
pub fn runtime_run(
&mut self,
command: String,
cx: &mut Context<Self>,
) -> Result<u64, crate::runtime_api::ApiError> {
crate::runtime_api::validate_command_line(&command)?;
if self.ssh_destination.is_some() {
return Err(crate::runtime_api::ApiError::new(
"exit_code_unavailable",
"pane.run is unavailable for native SSH panes because the remote integration does not report exit codes",
));
}
if let Some(reason) = &self.exited {
return Err(crate::runtime_api::ApiError::new(
"invalid_state",
format!("pane has exited: {reason}"),
));
}
self.ensure_runtime_readable()?;
if self.command_running || self.active_run.is_some() {
return Err(crate::runtime_api::ApiError::new(
"run_in_progress",
"the pane is already running a command",
));
}
let run = crate::runtime_api::begin_runtime_run();
let run_id = run.run_id;
self.active_run = Some(run);
self.last_run = None;
self.command_running = true;
self.suggest.last_committed.clone_from(&command);
let mut bytes = command.into_bytes();
bytes.push(b'\r');
self.write_input(bytes, cx);
cx.emit(TerminalViewEvent::TitleChanged);
Ok(run_id)
}
pub fn runtime_active_run(&self) -> Option<crate::runtime_api::RuntimePaneRun> {
self.active_run
}
pub fn runtime_last_run(&self) -> Option<crate::runtime_api::RuntimeRunOutcome> {
self.last_run.clone()
}
pub fn runtime_prompt(
&mut self,
text: String,
@@ -101,7 +101,8 @@ impl NebulaWorkspace {
| RuntimeCommand::NewTab { .. }
| RuntimeCommand::Split { .. }
| RuntimeCommand::Prompt { .. }
| RuntimeCommand::SendKey { .. } => {
| RuntimeCommand::SendKey { .. }
| RuntimeCommand::Run { .. } => {
self.reveal_session(cx);
self.runtime_pending.push(dispatch);
},
@@ -281,6 +282,34 @@ impl NebulaWorkspace {
cx,
)
},
RuntimeCommand::Run { window_id, pane_id, command, .. } => {
self.runtime_window_requested(*window_id)?;
let Some(tab_ix) = self.tab_of_pane(*pane_id) else {
return Err(ApiError::new(
"target_not_found",
format!("pane {pane_id} does not exist"),
));
};
let view = match self.tabs.get(tab_ix) {
Some(WorkspaceTab::Terminal { panes, .. }) => panes
.iter()
.find(|pane| pane.id == *pane_id)
.expect("tab_of_pane resolved a terminal pane")
.view
.clone(),
_ => unreachable!("tab_of_pane only resolves terminal tabs"),
};
let run_id = view.update(cx, |view, cx| view.runtime_run(command.clone(), cx))?;
self.runtime_result(
json!({
"window_id": self.runtime_window_id,
"pane_id": pane_id,
"run_id": run_id
}),
window,
cx,
)
},
}
}
@@ -328,6 +357,8 @@ impl NebulaWorkspace {
agent: view.runtime_agent(),
task_state: view.runtime_task_state(),
state_change_seq: 0,
active_run: view.runtime_active_run(),
last_run: view.runtime_last_run(),
}
})
.collect();
+430 -2
View File
@@ -48,6 +48,7 @@ const MAX_CLIENTS: usize = 64;
const MAX_WAIT: Duration = Duration::from_secs(24 * 60 * 60);
static NEXT_REQUEST_ID: AtomicU64 = AtomicU64::new(1);
static NEXT_RUN_ID: AtomicU64 = AtomicU64::new(1);
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
@@ -298,6 +299,10 @@ pub struct RuntimePane {
/// time. Stamped by [`RuntimeHub::publish`]; projection callers leave it 0.
#[serde(default)]
pub state_change_seq: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub active_run: Option<RuntimePaneRun>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_run: Option<RuntimeRunOutcome>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@@ -350,6 +355,94 @@ pub struct RuntimePaneProcesses {
pub processes: Vec<RuntimeProcess>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum RuntimeRunPhase {
Submitted,
Started,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub struct RuntimePaneRun {
pub run_id: u64,
pub phase: RuntimeRunPhase,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum RuntimeRunState {
Finished,
Failed,
Unavailable,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ExitCodeCapability {
Supported,
Unavailable,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RuntimeRunOutcome {
pub run_id: u64,
pub state: RuntimeRunState,
pub exit_code: Option<i32>,
pub exit_code_capability: ExitCodeCapability,
#[serde(skip_serializing_if = "Option::is_none")]
pub unavailable_reason: Option<String>,
}
impl RuntimeRunOutcome {
pub(crate) fn command_done(run: RuntimePaneRun, exit_code: Option<i32>) -> Self {
if run.phase != RuntimeRunPhase::Started {
return Self::unavailable(run.run_id, "command_start_not_observed");
}
match exit_code {
Some(0) => Self {
run_id: run.run_id,
state: RuntimeRunState::Finished,
exit_code,
exit_code_capability: ExitCodeCapability::Supported,
unavailable_reason: None,
},
Some(_) => Self {
run_id: run.run_id,
state: RuntimeRunState::Failed,
exit_code,
exit_code_capability: ExitCodeCapability::Supported,
unavailable_reason: None,
},
None => Self::unavailable(run.run_id, "exit_code_not_reported"),
}
}
pub(crate) fn unavailable(run_id: u64, reason: impl Into<String>) -> Self {
Self {
run_id,
state: RuntimeRunState::Unavailable,
exit_code: None,
exit_code_capability: ExitCodeCapability::Unavailable,
unavailable_reason: Some(reason.into()),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RuntimeRunResult {
pub window_id: u64,
pub pane_id: u64,
#[serde(flatten)]
pub outcome: RuntimeRunOutcome,
}
pub(crate) fn begin_runtime_run() -> RuntimePaneRun {
RuntimePaneRun {
run_id: NEXT_RUN_ID.fetch_add(1, Ordering::Relaxed),
phase: RuntimeRunPhase::Submitted,
}
}
/// Named control keys accepted by `pane.send_key`. Printable text is
/// intentionally absent; callers must use `pane.prompt` for text input.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
@@ -522,6 +615,13 @@ pub enum RuntimeCommand {
modifiers: RuntimeKeyModifiers,
repeat: u16,
},
Run {
window_id: Option<u64>,
pane_id: u64,
command: String,
wait: bool,
timeout_ms: u64,
},
}
#[derive(Debug)]
@@ -621,6 +721,10 @@ struct HubState {
current: Option<RuntimeSnapshot>,
next_subscription: u64,
subscribers: Vec<(u64, SyncSender<RuntimeSnapshot>)>,
next_run_waiter: u64,
run_waiters:
std::collections::HashMap<(u64, u64, u64), Vec<(u64, SyncSender<RuntimeRunResult>)>>,
completed_runs: std::collections::VecDeque<RuntimeRunResult>,
}
impl RuntimeHub {
@@ -640,6 +744,7 @@ impl RuntimeHub {
// whose state is unchanged keep their previous counter, so an
// otherwise-identical projection still compares equal here.
stamp_state_change_seq(state.current.as_ref(), &mut snapshot);
observe_run_lifecycle(&mut state, &snapshot);
if let Some(current) = &state.current {
snapshot.revision = current.revision;
if current == &snapshot {
@@ -673,6 +778,164 @@ impl RuntimeHub {
fn current(&self) -> Option<RuntimeSnapshot> {
self.lock().current.clone()
}
fn wait_run(
&self,
window_id: u64,
pane_id: u64,
run_id: u64,
timeout: Duration,
) -> Result<RuntimeRunResult, ApiError> {
let key = (window_id, pane_id, run_id);
let (waiter_id, receiver) = {
let mut state = self.lock();
if let Some(result) = state.completed_runs.iter().find(|result| {
result.window_id == window_id
&& result.pane_id == pane_id
&& result.outcome.run_id == run_id
}) {
return run_result_or_capability_error(result.clone());
}
let (sender, receiver) = mpsc::sync_channel(1);
state.next_run_waiter = state.next_run_waiter.saturating_add(1);
let waiter_id = state.next_run_waiter;
state.run_waiters.entry(key).or_default().push((waiter_id, sender));
(waiter_id, receiver)
};
match receiver.recv_timeout(timeout) {
Ok(result) => run_result_or_capability_error(result),
Err(RecvTimeoutError::Disconnected) => Err(ApiError::new(
"runtime_unavailable",
"runtime run completion channel disconnected",
)),
Err(RecvTimeoutError::Timeout) => {
let mut state = self.lock();
if let Some(waiters) = state.run_waiters.get_mut(&key) {
waiters.retain(|(id, _)| *id != waiter_id);
if waiters.is_empty() {
state.run_waiters.remove(&key);
}
}
let phase = state.current.as_ref().and_then(|snapshot| {
snapshot
.pane(Some(window_id), pane_id)
.ok()
.and_then(|pane| pane.active_run)
.filter(|run| run.run_id == run_id)
.map(|run| run.phase)
});
let (code, message) = match phase {
Some(RuntimeRunPhase::Submitted) => (
"run_start_timeout",
"the shell did not report CommandStart before timeout",
),
Some(RuntimeRunPhase::Started) => {
("timeout", "the command did not report CommandDone before timeout")
},
None => ("run_aborted", "the pane or run disappeared before completion"),
};
Err(ApiError::new(code, message).details(json!({
"window_id": window_id,
"pane_id": pane_id,
"run_id": run_id,
"phase": phase
})))
},
}
}
}
const COMPLETED_RUN_CACHE: usize = 256;
fn observe_run_lifecycle(state: &mut HubState, snapshot: &RuntimeSnapshot) {
let mut observed = std::collections::HashMap::new();
for window in &snapshot.windows {
for tab in &window.tabs {
for pane in &tab.panes {
if let Some(active) = pane.active_run {
observed.insert((window.id, pane.id, active.run_id), active.phase);
}
let Some(outcome) = pane.last_run.clone() else {
continue;
};
let already_cached = state.completed_runs.iter().any(|result| {
result.window_id == window.id
&& result.pane_id == pane.id
&& result.outcome.run_id == outcome.run_id
});
if !already_cached {
complete_run(
state,
RuntimeRunResult { window_id: window.id, pane_id: pane.id, outcome },
);
}
}
}
}
let previous_active: Vec<_> = state
.current
.iter()
.flat_map(|previous| previous.windows.iter())
.flat_map(|window| {
window.tabs.iter().flat_map(move |tab| {
tab.panes.iter().filter_map(move |pane| {
pane.active_run.map(|run| (window.id, pane.id, run.run_id))
})
})
})
.collect();
for key @ (window_id, pane_id, run_id) in previous_active {
if observed.contains_key(&key)
|| state.completed_runs.iter().any(|result| {
result.window_id == window_id
&& result.pane_id == pane_id
&& result.outcome.run_id == run_id
})
{
continue;
}
complete_run(
state,
RuntimeRunResult {
window_id,
pane_id,
outcome: RuntimeRunOutcome::unavailable(run_id, "pane_or_run_disappeared"),
},
);
}
}
fn complete_run(state: &mut HubState, result: RuntimeRunResult) {
let key = (result.window_id, result.pane_id, result.outcome.run_id);
if let Some(waiters) = state.run_waiters.remove(&key) {
for (_, sender) in waiters {
let _ = sender.try_send(result.clone());
}
}
state.completed_runs.push_back(result);
while state.completed_runs.len() > COMPLETED_RUN_CACHE {
state.completed_runs.pop_front();
}
}
fn run_result_or_capability_error(result: RuntimeRunResult) -> Result<RuntimeRunResult, ApiError> {
if result.outcome.state != RuntimeRunState::Unavailable {
return Ok(result);
}
let reason = result
.outcome
.unavailable_reason
.clone()
.unwrap_or_else(|| "exit_code_unavailable".to_owned());
let code = match reason.as_str() {
"command_start_not_observed" => "shell_integration_unavailable",
"pane_or_run_disappeared" => "run_aborted",
_ => "exit_code_unavailable",
};
Err(ApiError::new(code, "the command completed without a reliable exit-code result")
.details(serde_json::to_value(result).unwrap_or_else(|_| json!({ "reason": reason }))))
}
#[derive(Debug, Deserialize)]
@@ -751,6 +1014,19 @@ struct SendKeyParams {
repeat: u16,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct RunParams {
#[serde(default)]
window_id: Option<u64>,
pane_id: u64,
command: String,
#[serde(default = "default_true")]
wait: bool,
#[serde(default = "default_run_timeout_ms")]
timeout_ms: u64,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct SubscribeParams {
@@ -798,6 +1074,10 @@ fn default_key_repeat() -> u16 {
1
}
fn default_run_timeout_ms() -> u64 {
COMMAND_TIMEOUT.as_millis() as u64
}
impl RuntimeCommand {
fn from_request(request: &ApiRequest) -> Result<Self, ApiError> {
match request.method.as_str() {
@@ -862,6 +1142,22 @@ impl RuntimeCommand {
repeat: params.repeat,
})
},
"pane.run" => {
let params: RunParams = parse_params(&request.params)?;
validate_command_line(&params.command)?;
if params.timeout_ms == 0 || Duration::from_millis(params.timeout_ms) > MAX_WAIT {
return Err(ApiError::invalid_params(
"timeout_ms must be between 1 and 86400000",
));
}
Ok(Self::Run {
window_id: params.window_id,
pane_id: params.pane_id,
command: params.command,
wait: params.wait,
timeout_ms: params.timeout_ms,
})
},
method => Err(ApiError::new(
"method_not_found",
format!("runtime API method {method:?} does not exist"),
@@ -991,6 +1287,23 @@ pub(crate) fn validate_prompt(text: &str) -> Result<(), ApiError> {
Ok(())
}
pub(crate) fn validate_command_line(command: &str) -> Result<(), ApiError> {
if command.trim().is_empty() {
return Err(ApiError::invalid_params("command must not be empty"));
}
if command.len() > MAX_PROMPT_BYTES {
return Err(ApiError::invalid_params(format!(
"command exceeds the {MAX_PROMPT_BYTES}-byte limit"
)));
}
if command.chars().any(char::is_control) {
return Err(ApiError::invalid_params(
"command contains control characters; pane.run accepts one plain-text shell line",
));
}
Ok(())
}
/// A wait is satisfied only when the pane both reads as the requested state and
/// has moved past the caller's baseline. Without the counter check, waiting on
/// an already-idle pane returns immediately and the caller concludes its work
@@ -1274,7 +1587,7 @@ fn handle_connection(
"events.subscribe" => subscribe_connection(&mut stream, request, hub),
"agents.list" => agents_connection(&mut stream, request, hub),
"pane.wait" => wait_connection(&mut stream, request, hub),
_ => dispatch_connection(&mut stream, request, sink),
_ => dispatch_connection(&mut stream, request, sink, hub),
}
}
@@ -1319,6 +1632,7 @@ fn runtime_description() -> Value {
"pane.read",
"pane.procs",
"pane.send_key",
"pane.run",
"pane.wait"
],
// Additive params cannot be detected from `capabilities`: an older
@@ -1521,6 +1835,7 @@ fn dispatch_connection(
stream: &mut TcpStream,
request: ApiRequest,
sink: &EventSink,
hub: &RuntimeHub,
) -> Result<(), IoError> {
let command = match RuntimeCommand::from_request(&request) {
Ok(command) => command,
@@ -1536,6 +1851,12 @@ fn dispatch_connection(
modifiers.control,
);
}
let run_wait = match &command {
RuntimeCommand::Run { wait: true, timeout_ms, .. } => {
Some(Duration::from_millis(*timeout_ms))
},
_ => None,
};
let (dispatch, receiver) = RuntimeDispatch::new(command);
if !sink.emit_control(dispatch) {
return write_response(
@@ -1547,7 +1868,36 @@ fn dispatch_connection(
);
}
let response = match receiver.recv_timeout(COMMAND_TIMEOUT) {
Ok(Ok(result)) => ApiResponse::success(request.id, result),
Ok(Ok(result)) => {
if let Some(timeout) = run_wait {
let action = result.get("action").unwrap_or(&result);
let target = (
action.get("window_id").and_then(Value::as_u64),
action.get("pane_id").and_then(Value::as_u64),
action.get("run_id").and_then(Value::as_u64),
);
match target {
(Some(window_id), Some(pane_id), Some(run_id)) => {
match hub.wait_run(window_id, pane_id, run_id, timeout) {
Ok(run) => ApiResponse::success(
request.id,
json!({ "run": run, "snapshot": hub.current() }),
),
Err(error) => ApiResponse::failure(request.id, error),
}
},
_ => ApiResponse::failure(
request.id,
ApiError::new(
"invalid_runtime_response",
"pane.run did not return its window, pane, and run identity",
),
),
}
} else {
ApiResponse::success(request.id, result)
}
},
Ok(Err(error)) => ApiResponse::failure(request.id, error),
Err(_) => ApiResponse::failure(
request.id,
@@ -1717,6 +2067,20 @@ pub fn run_cli(options: ControlOptions) -> Result<(), Box<dyn Error>> {
)?;
print_response(&response, options.pretty)
},
CliCommand::Run { window, pane, command, no_wait } => {
let response = request_once(
"pane.run",
json!({
"window_id": window,
"pane_id": pane,
"command": command,
"wait": !no_wait,
"timeout_ms": options.timeout_ms
}),
timeout.saturating_add(Duration::from_secs(1)),
)?;
print_response(&response, options.pretty)
},
CliCommand::Wait { window, pane, state, after_seq } => {
wait_cli(window, pane, state, after_seq, timeout, options.pretty)
},
@@ -1839,6 +2203,8 @@ mod tests {
agent: None,
task_state: state,
state_change_seq: 0,
active_run: None,
last_run: None,
}],
}],
}],
@@ -1905,6 +2271,68 @@ mod tests {
);
}
#[test]
fn run_requires_one_plain_shell_line() {
let valid = ApiRequest::new(
"token".into(),
"pane.run",
json!({ "pane_id": 3, "command": "cargo test", "wait": true }),
);
assert!(matches!(
RuntimeCommand::from_request(&valid),
Ok(RuntimeCommand::Run { wait: true, .. })
));
let multiline = ApiRequest::new(
"token".into(),
"pane.run",
json!({ "pane_id": 3, "command": "echo one\necho two" }),
);
assert_eq!(RuntimeCommand::from_request(&multiline).unwrap_err().code, "invalid_params");
}
#[test]
fn run_outcome_requires_a_real_start_and_exit_code() {
let submitted = RuntimePaneRun { run_id: 41, phase: RuntimeRunPhase::Submitted };
let no_start = RuntimeRunOutcome::command_done(submitted, Some(0));
assert_eq!(no_start.state, RuntimeRunState::Unavailable);
assert_eq!(no_start.unavailable_reason.as_deref(), Some("command_start_not_observed"));
let started = RuntimePaneRun { run_id: 42, phase: RuntimeRunPhase::Started };
assert_eq!(
RuntimeRunOutcome::command_done(started, Some(0)).state,
RuntimeRunState::Finished
);
assert_eq!(
RuntimeRunOutcome::command_done(started, Some(7)).state,
RuntimeRunState::Failed
);
let missing_code = RuntimeRunOutcome::command_done(started, None);
assert_eq!(missing_code.state, RuntimeRunState::Unavailable);
assert_eq!(missing_code.exit_code_capability, ExitCodeCapability::Unavailable);
}
#[test]
fn completed_run_cache_closes_the_waiter_registration_race() {
let hub = RuntimeHub::new();
let mut running = snapshot(RuntimeTaskState::Running);
running.windows[0].tabs[0].panes[0].active_run =
Some(RuntimePaneRun { run_id: 51, phase: RuntimeRunPhase::Started });
hub.publish(running);
let mut done = snapshot(RuntimeTaskState::Finished);
done.windows[0].tabs[0].panes[0].last_run = Some(RuntimeRunOutcome::command_done(
RuntimePaneRun { run_id: 51, phase: RuntimeRunPhase::Started },
Some(0),
));
hub.publish(done);
// The result was published before this waiter existed. The bounded
// cache must still return the exact run rather than timing out.
let result = hub.wait_run(7, 3, 51, Duration::from_millis(10)).unwrap();
assert_eq!(result.outcome.exit_code, Some(0));
}
#[test]
fn settled_wait_excludes_only_running() {
assert!(!wait_state_matches(RuntimeTaskState::Running, RuntimeWaitState::Settled));
+34
View File
@@ -45,6 +45,8 @@ impl WindowContext {
// counter at publish time, where the previous snapshot
// is available to compare against.
state_change_seq: 0,
active_run: pane.nebula_state.active_run,
last_run: pane.nebula_state.last_run.clone(),
})
.collect();
let label = tab.custom_name.clone().unwrap_or_else(|| {
@@ -253,6 +255,38 @@ impl WindowContext {
self.display.window.request_redraw();
Ok(bytes_sent)
}
pub(crate) fn runtime_run(&mut self, pane_id: u64, command: String) -> Result<u64, ApiError> {
crate::runtime_api::validate_command_line(&command)?;
let Some(index) = self.pane_index(pane_id) else {
return Err(ApiError::new(
"target_not_found",
format!("pane {pane_id} does not belong to the target window"),
));
};
let pane = &mut self.panes[index];
if pane.ssh_destination.is_some() {
return Err(ApiError::new(
"exit_code_unavailable",
"pane.run is unavailable for native SSH panes because the remote integration does not report exit codes",
));
}
if pane.nebula_state.command_started.is_some() || pane.nebula_state.active_run.is_some() {
return Err(ApiError::new("run_in_progress", "the pane is already running a command"));
}
let run = crate::runtime_api::begin_runtime_run();
let run_id = run.run_id;
pane.nebula_state.active_run = Some(run);
pane.nebula_state.last_run = None;
pane.nebula_state.last_committed.clone_from(&command);
pane.nebula_state.touched = true;
pane.nebula_state.awaiting_input = false;
pane.notifier.notify(command.into_bytes());
pane.notifier.notify(vec![b'\r']);
self.dirty = true;
self.display.window.request_redraw();
Ok(run_id)
}
}
fn runtime_agent(pane: &super::Pane) -> Option<RuntimeAgent> {