diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-03-29 04:23:22 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-03-29 04:23:22 +0300 |
| commit | fd1e3b8b056b2937fc0a5c90bf482e01d269f6b2 (patch) | |
| tree | 4bd41fc4c4682c4fae42e5ea742ee2b93c7672ff /src/api/schema.rs | |
| parent | 60baa765d942dcc40ee8bf0e58400aa634b6741a (diff) | |
feat: add initial socket api for workspace and pane control
add unix socket request/response api scaffolding
implement initial workspace and pane commands:
- ping
- workspace.list
- workspace.get
- workspace.create
- pane.list
- pane.get
- pane.read
- pane.send_text
- pane.send_keys
- pane.wait_for_output
improve pane read semantics:
- visible trims trailing blank viewport rows
- recent reads from parser scrollback/current screen
- wait_for_output returns matched snapshot plus matched_line
add real subprocess integration tests for socket api
Diffstat (limited to 'src/api/schema.rs')
| -rw-r--r-- | src/api/schema.rs | 537 |
1 files changed, 537 insertions, 0 deletions
diff --git a/src/api/schema.rs b/src/api/schema.rs new file mode 100644 index 00000000..29509211 --- /dev/null +++ b/src/api/schema.rs @@ -0,0 +1,537 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Request { + pub id: String, + #[serde(flatten)] + pub method: Method, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "method", content = "params")] +pub enum Method { + #[serde(rename = "ping")] + Ping(PingParams), + #[serde(rename = "workspace.create")] + WorkspaceCreate(WorkspaceCreateParams), + #[serde(rename = "workspace.list")] + WorkspaceList(EmptyParams), + #[serde(rename = "workspace.get")] + WorkspaceGet(WorkspaceTarget), + #[serde(rename = "workspace.focus")] + WorkspaceFocus(WorkspaceTarget), + #[serde(rename = "workspace.rename")] + WorkspaceRename(WorkspaceRenameParams), + #[serde(rename = "workspace.close")] + WorkspaceClose(WorkspaceTarget), + #[serde(rename = "pane.split")] + PaneSplit(PaneSplitParams), + #[serde(rename = "pane.list")] + PaneList(PaneListParams), + #[serde(rename = "pane.get")] + PaneGet(PaneTarget), + #[serde(rename = "pane.send_text")] + PaneSendText(PaneSendTextParams), + #[serde(rename = "pane.send_keys")] + PaneSendKeys(PaneSendKeysParams), + #[serde(rename = "pane.read")] + PaneRead(PaneReadParams), + #[serde(rename = "pane.close")] + PaneClose(PaneTarget), + #[serde(rename = "events.subscribe")] + EventsSubscribe(EventsSubscribeParams), + #[serde(rename = "events.wait")] + EventsWait(EventsWaitParams), + #[serde(rename = "pane.wait_for_output")] + PaneWaitForOutput(PaneWaitForOutputParams), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct EmptyParams {} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct PingParams {} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorkspaceTarget { + pub workspace_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PaneTarget { + pub pane_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorkspaceCreateParams { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cwd: Option<String>, + #[serde(default)] + pub focus: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorkspaceRenameParams { + pub workspace_id: String, + pub label: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PaneSplitParams { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option<String>, + pub target_pane_id: String, + pub direction: SplitDirection, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cwd: Option<String>, + #[serde(default)] + pub focus: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SplitDirection { + Right, + Down, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct PaneListParams { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option<String>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PaneSendTextParams { + pub pane_id: String, + pub text: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PaneSendKeysParams { + pub pane_id: String, + pub keys: Vec<String>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PaneReadParams { + pub pane_id: String, + pub source: ReadSource, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub lines: Option<u32>, + #[serde(default = "default_true")] + pub strip_ansi: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ReadSource { + Visible, + Recent, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct EventsSubscribeParams { + pub events: Vec<EventKind>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pane_id: Option<String>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct EventsWaitParams { + pub match_event: EventMatch, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timeout_ms: Option<u64>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PaneWaitForOutputParams { + pub pane_id: String, + pub source: ReadSource, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub lines: Option<u32>, + pub r#match: OutputMatch, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timeout_ms: Option<u64>, + #[serde(default = "default_true")] + pub strip_ansi: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum OutputMatch { + Substring { value: String }, + Regex { value: String }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "event", rename_all = "snake_case")] +pub enum EventMatch { + WorkspaceCreated { + #[serde(default, skip_serializing_if = "Option::is_none")] + workspace_id: Option<String>, + }, + WorkspaceClosed { + workspace_id: String, + }, + WorkspaceRenamed { + workspace_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + label: Option<String>, + }, + WorkspaceFocused { + workspace_id: String, + }, + PaneCreated { + #[serde(default, skip_serializing_if = "Option::is_none")] + pane_id: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + workspace_id: Option<String>, + }, + PaneClosed { + pane_id: String, + }, + PaneFocused { + pane_id: String, + }, + PaneOutputChanged { + pane_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + min_revision: Option<u64>, + }, + PaneExited { + pane_id: String, + }, + PaneAgentDetected { + pane_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + agent: Option<String>, + }, + PaneAgentStateChanged { + pane_id: String, + state: PaneAgentState, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum EventKind { + WorkspaceCreated, + WorkspaceClosed, + WorkspaceRenamed, + WorkspaceFocused, + PaneCreated, + PaneClosed, + PaneFocused, + PaneOutputChanged, + PaneExited, + PaneAgentDetected, + PaneAgentStateChanged, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SuccessResponse { + pub id: String, + pub result: ResponseResult, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ErrorResponse { + pub id: String, + pub error: ErrorBody, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ErrorBody { + pub code: String, + pub message: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ResponseResult { + Pong { + version: String, + }, + WorkspaceInfo { + workspace: WorkspaceInfo, + }, + WorkspaceList { + workspaces: Vec<WorkspaceInfo>, + }, + PaneInfo { + pane: PaneInfo, + }, + PaneList { + panes: Vec<PaneInfo>, + }, + PaneRead { + read: PaneReadResult, + }, + SubscriptionStarted { + events: Vec<EventKind>, + }, + WaitMatched { + event: EventEnvelope, + }, + OutputMatched { + pane_id: String, + revision: u64, + matched_line: Option<String>, + read: PaneReadResult, + }, + Ok {}, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorkspaceInfo { + pub workspace_id: String, + pub number: usize, + pub label: String, + pub focused: bool, + pub pane_count: usize, + pub agent_state: PaneAgentState, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PaneInfo { + pub pane_id: String, + pub workspace_id: String, + pub focused: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cwd: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent: Option<String>, + pub agent_state: PaneAgentState, + pub revision: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PaneReadResult { + pub pane_id: String, + pub workspace_id: String, + pub source: ReadSource, + pub text: String, + pub revision: u64, + pub truncated: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct EventEnvelope { + pub event: EventKind, + pub data: EventData, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum EventData { + WorkspaceCreated { + workspace: WorkspaceInfo, + }, + WorkspaceClosed { + workspace_id: String, + }, + WorkspaceRenamed { + workspace_id: String, + label: String, + }, + WorkspaceFocused { + workspace_id: String, + }, + PaneCreated { + pane: PaneInfo, + }, + PaneClosed { + pane_id: String, + workspace_id: String, + }, + PaneFocused { + pane_id: String, + workspace_id: String, + }, + PaneOutputChanged { + pane_id: String, + workspace_id: String, + revision: u64, + }, + PaneExited { + pane_id: String, + workspace_id: String, + }, + PaneAgentDetected { + pane_id: String, + workspace_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + agent: Option<String>, + }, + PaneAgentStateChanged { + pane_id: String, + workspace_id: String, + state: PaneAgentState, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PaneAgentState { + Idle, + Busy, + Waiting, + Unknown, +} + +fn default_true() -> bool { + true +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn request_round_trips_for_pane_read() { + let request = Request { + id: "req_1".into(), + method: Method::PaneRead(PaneReadParams { + pane_id: "p_1".into(), + source: ReadSource::Recent, + lines: Some(80), + strip_ansi: true, + }), + }; + + let json = serde_json::to_string(&request).unwrap(); + let restored: Request = serde_json::from_str(&json).unwrap(); + assert_eq!(restored, request); + } + + #[test] + fn request_uses_dot_method_names() { + let request = Request { + id: "req_1".into(), + method: Method::WorkspaceCreate(WorkspaceCreateParams { + cwd: Some("/tmp".into()), + focus: true, + }), + }; + + let json = serde_json::to_value(&request).unwrap(); + assert_eq!(json["method"], "workspace.create"); + } + + #[test] + fn unknown_method_is_rejected() { + let json = r#"{"id":"req_1","method":"nope","params":{}}"#; + let err = serde_json::from_str::<Request>(json) + .unwrap_err() + .to_string(); + assert!(err.contains("unknown variant")); + } + + #[test] + fn missing_required_params_are_rejected() { + let json = r#"{"id":"req_1","method":"pane.send_text","params":{"pane_id":"p_1"}}"#; + let err = serde_json::from_str::<Request>(json) + .unwrap_err() + .to_string(); + assert!(err.contains("text")); + } + + #[test] + fn pane_wait_for_output_defaults_strip_ansi_to_true() { + let json = r#" + { + "id": "req_1", + "method": "pane.wait_for_output", + "params": { + "pane_id": "p_1", + "source": "recent", + "match": { "type": "substring", "value": "ready" } + } + } + "#; + + let request: Request = serde_json::from_str(json).unwrap(); + let Method::PaneWaitForOutput(params) = request.method else { + panic!("wrong method parsed"); + }; + assert!(params.strip_ansi); + } + + #[test] + fn event_envelope_round_trips() { + let event = EventEnvelope { + event: EventKind::PaneOutputChanged, + data: EventData::PaneOutputChanged { + pane_id: "p_1".into(), + workspace_id: "w_1".into(), + revision: 42, + }, + }; + + let json = serde_json::to_string(&event).unwrap(); + let restored: EventEnvelope = serde_json::from_str(&json).unwrap(); + assert_eq!(restored, event); + } + + #[test] + fn success_response_round_trips() { + let response = SuccessResponse { + id: "req_1".into(), + result: ResponseResult::Pong { + version: "0.1.2".into(), + }, + }; + + let json = serde_json::to_string(&response).unwrap(); + let restored: SuccessResponse = serde_json::from_str(&json).unwrap(); + assert_eq!(restored, response); + } + + #[test] + fn error_response_round_trips() { + let response = ErrorResponse { + id: "req_1".into(), + error: ErrorBody { + code: "pane_not_found".into(), + message: "pane p_1 not found".into(), + }, + }; + + let json = serde_json::to_string(&response).unwrap(); + let restored: ErrorResponse = serde_json::from_str(&json).unwrap(); + assert_eq!(restored, response); + } + + #[test] + fn event_wait_parses_typed_match() { + let json = r#" + { + "id": "req_9", + "method": "events.wait", + "params": { + "match_event": { + "event": "pane_agent_state_changed", + "pane_id": "p_1", + "state": "waiting" + }, + "timeout_ms": 30000 + } + } + "#; + + let request: Request = serde_json::from_str(json).unwrap(); + let Method::EventsWait(params) = request.method else { + panic!("wrong method parsed"); + }; + assert_eq!( + params.match_event, + EventMatch::PaneAgentStateChanged { + pane_id: "p_1".into(), + state: PaneAgentState::Waiting, + } + ); + } +} |
