Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ dyn-clone = { version = "1", default-features = false }
dyn-hash = { version = "1", default-features = false }
eventsource-stream = { version = "0.2", default-features = false }
fancy-regex = { version = "0.17", default-features = false }
form_urlencoded = { version = "1", default-features = false, features = ["alloc"] }
futures = { version = "0.3", default-features = false }
gemini_client_rs = { git = "https://github.com/JeanMertz/gemini-client", default-features = false } # <https://github.com/Adriftdev/gemini-client/pull/16>
glob = { version = "0.3", default-features = false }
Expand Down
52 changes: 49 additions & 3 deletions crates/jp_cli/src/cmd/plugin/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,12 @@ use jp_plugin::{
ComposeMode, ComposeOption, ComposeRequest, ComposeResponse, ConfigEntry, ConfigResponse,
ConfigsResponse, ConversationSummary, ConversationsResponse, CreatedResponse,
DescribeResponse, DoneResponse, DraftResponse, ErrorResponse, EventsResponse, HostToPlugin,
InitMessage, LogMessage, PathsInfo, PluginToHost, QueryCompleteResponse, QueryRequest,
SetTitleRequest, WorkspaceInfo, WriteDraftRequest,
InitMessage, LockState, LogMessage, OutputFormat as PluginOutputFormat, PathsInfo,
PluginToHost, QueryCompleteResponse, QueryRequest, SetTitleRequest, WorkspaceInfo,
WriteDraftRequest,
},
};
use jp_printer::Printer;
use jp_printer::{OutputFormat, Printer};
use jp_storage::backend::FsStorageBackend;
use jp_workspace::{ConversationLock, LockResult, Workspace, session::Session};
use relative_path::RelativePath;
Expand Down Expand Up @@ -254,6 +255,7 @@ pub(crate) async fn run_plugin(
options,
args: args.to_vec(),
log_level,
output_format: output_format(ctx.printer.format()),
});

let PluginProcess {
Expand Down Expand Up @@ -411,6 +413,19 @@ fn stop_plugin(stdin: &Mutex<impl Write>, shutdown_sent: &AtomicBool, child_id:
kill_child(child_id);
}

/// The host's output format, in the protocol's vocabulary.
///
/// Two enums rather than one shared type: the protocol should not depend on a
/// particular renderer, so it carries its own.
fn output_format(format: OutputFormat) -> PluginOutputFormat {
match format {
OutputFormat::Text => PluginOutputFormat::Text,
OutputFormat::TextPretty => PluginOutputFormat::TextPretty,
OutputFormat::Json => PluginOutputFormat::Json,
OutputFormat::JsonPretty => PluginOutputFormat::JsonPretty,
}
}

/// The JP directories a plugin is told about, so it needs no platform logic of
/// its own.
fn well_known_paths(user_storage_path: Option<&Utf8Path>) -> PathsInfo {
Expand Down Expand Up @@ -1398,13 +1413,44 @@ fn handle_read_events(
jp_conversation::decode_event_value(value);
}

// Carried here so labelling one conversation doesn't cost a plugin the whole
// conversation list, which reads every conversation's metadata.
let title = workspace
.metadata(&handle)
.ok()
.and_then(|meta| meta.title.clone());

HostToPlugin::Events(EventsResponse {
id: req_id,
conversation: conversation_id.to_owned(),
lock: lock_state(workspace, &conv_id),
title,
data: event_values,
})
}

/// Whether a turn is running on a conversation, and whose it is.
///
/// Read from the lock rather than from the transcript: a stream ending in a
/// request looks identical whether a turn is running, was interrupted, or
/// failed outright.
///
/// A lock file outlives the process that wrote it when that process is killed,
/// so a recorded holder that is no longer alive counts as no holder at all.
/// Otherwise a crashed run would leave a conversation looking busy forever.
fn lock_state(workspace: &Workspace, id: &ConversationId) -> LockState {
workspace
.conversation_lock_info(id)
.filter(|info| is_process_alive(info.pid))
.map_or(LockState::Free, |info| {
if info.pid == std::process::id() {
LockState::Here
} else {
LockState::Elsewhere
}
})
}

fn handle_read_config(
config_json: &Value,
path: Option<String>,
Expand Down
111 changes: 111 additions & 0 deletions crates/jp_plugin/src/message.rs
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,90 @@ impl PluginToHost {

// --- Host-to-Plugin messages ---

/// How the host renders what it prints.
///
/// A plugin reads this to decide the shape of its own output, so `jp --format
/// json` reaches a plugin's listings the way it reaches the host's own commands
/// and a caller does not have to learn a separate flag per plugin.
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum OutputFormat {
/// Plain text, with no ANSI colors and no unicode decoration.
#[default]
Text,

/// Text with ANSI colors and unicode decoration.
TextPretty,

/// Compact JSON, one line per print.
Json,

/// Indented JSON.
JsonPretty,
}

impl OutputFormat {
/// Whether output should be machine-readable.
#[must_use]
pub const fn is_json(self) -> bool {
matches!(self, Self::Json | Self::JsonPretty)
}

/// Whether JSON output should be indented.
#[must_use]
pub const fn is_json_pretty(self) -> bool {
matches!(self, Self::JsonPretty)
}

/// Whether text output can carry ANSI colors and unicode decoration.
#[must_use]
pub const fn is_pretty(self) -> bool {
matches!(self, Self::TextPretty)
}
}

/// Who holds a conversation.
///
/// A conversation is locked for the length of a turn, so this says whether one
/// is running, and whether it is the reader's to interrupt.
/// A turn in another process can be waited for but not signalled from here.
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum LockState {
/// Nobody.
/// No turn is running.
#[default]
Free,

/// A turn in another process.
Elsewhere,

/// A turn in the host answering this request.
Here,
}

impl LockState {
/// Whether no turn is running.
///
/// Takes a reference because `skip_serializing_if` calls it with one.
#[must_use]
pub const fn is_free(&self) -> bool {
matches!(self, Self::Free)
}

/// Whether a turn is running, wherever it is.
#[must_use]
pub const fn is_held(&self) -> bool {
!self.is_free()
}

/// Whether the running turn can be interrupted through this connection.
#[must_use]
pub const fn is_here(&self) -> bool {
matches!(self, Self::Here)
}
}

/// The `init` message sent to the plugin on startup.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct InitMessage {
Expand Down Expand Up @@ -227,6 +311,18 @@ pub struct InitMessage {
/// that stderr output matches the host's `-v` flags.
#[serde(default)]
pub log_level: u8,

/// The shape the host's own output takes, resolved from `--format`.
///
/// A plugin that prints listings or records should match it, so one flag
/// governs the whole invocation.
///
/// Reads as [`OutputFormat::Text`] when the host is old enough not to send
/// it, which is the shape plugins printed before they could ask.
/// That fallback is why this needs no protocol version of its own: there is
/// nothing a plugin has to refuse to run without.
#[serde(default)]
pub output_format: OutputFormat,
}

/// Workspace metadata included in the `init` message.
Expand Down Expand Up @@ -279,6 +375,21 @@ pub struct EventsResponse {
/// The conversation ID.
pub conversation: String,

/// Who holds this conversation, if anyone.
///
/// Read from the conversation lock, which is the only authoritative answer:
/// a transcript ending in a request looks identical whether a turn is
/// running, was interrupted, or failed outright.
#[serde(default, skip_serializing_if = "LockState::is_free")]
pub lock: LockState,

/// The conversation's title, if it has one.
///
/// Saves a plugin from asking for the whole conversation list to label one
/// conversation, which reads every conversation's metadata.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,

/// Serialized conversation events.
pub data: Vec<Value>,
}
Expand Down
1 change: 1 addition & 0 deletions crates/jp_plugin/src/message_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ fn host_init_roundtrip() {
options: Map::from_iter([("port".to_owned(), json!(8080))]),
args: vec!["--web".to_owned()],
log_level: 0,
output_format: OutputFormat::JsonPretty,
});

let json = serde_json::to_string(&msg).unwrap();
Expand Down
3 changes: 2 additions & 1 deletion crates/jp_plugin/src/protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ use crate::message::{ExitMessage, ReadyMessage};
/// | 5 | `list_configs`, naming the configurations a query can select. |
/// | 6 | `query`, with `created` and `query_complete` in reply. |
/// | 7 | `interrupt`, for stopping a turn the host is running. |
pub const PROTOCOL_VERSION: u32 = 7;
/// | 8 | `lock` on `events`, saying whether a turn is running. |
pub const PROTOCOL_VERSION: u32 = 8;

/// Answer a host's `init`, refusing it when it is too old to serve this plugin.
///
Expand Down
8 changes: 5 additions & 3 deletions crates/plugins/command/serve-web/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
name = "jp-serve-web"

authors.workspace = true
description = "Read-only web UI for browsing JP conversations."
description = "Web UI for browsing JP conversations and continuing them."
documentation.workspace = true
edition.workspace = true
homepage.workspace = true
Expand All @@ -15,10 +15,12 @@ version.workspace = true
[dependencies]
jp_plugin = { workspace = true }

axum = { workspace = true, features = ["http1", "tokio"] }
axum = { workspace = true, features = ["form", "http1", "json", "query", "tokio"] }
chrono = { workspace = true }
comrak = { workspace = true }
form_urlencoded = { workspace = true }
maud = { workspace = true, features = ["axum"] }
serde = { workspace = true, features = ["derive", "std"] }
serde_json = { workspace = true, features = ["std"] }
sha2 = { workspace = true }
tokio = { workspace = true }
Expand All @@ -35,7 +37,7 @@ workspace = true
[package.metadata.jp-registry]
id = "serve-web"
command = ["serve", "web"]
description = "Read-only web UI for browsing conversations"
description = "Web UI for browsing conversations and continuing them"
official = true
requires = ["serve"]
repository = "https://github.com/dcdpr/jp"
Expand Down
Loading
Loading