Repository navigation
Implementation plan: morphir-host Phase A, part 1 (portable session core) #976
Replies: 10 comments
Task 1: Create the
|
Task 2: Move
|
Task 3:
|
| Event and state | Action | Next state |
|---|---|---|
Open in Loaded |
Send(initialize, id 1) |
Initializing |
Received ok, negotiation passes |
Ready |
Ready |
Received RPC error, invalid envelope, bad result, or checks fail during Initializing |
Failed |
Failed |
Call in Ready, method is initialize, shutdown or exit |
Rejected("Protocol lifecycle method '{m}' must use its dedicated session operation") |
Ready |
Call in Ready, not supports_method |
Rejected("RPC error -32013: Extension does not support capability '{m}'") |
Ready |
Call in Ready, not supports_invocation |
Rejected("RPC error -32013: Extension does not support capability '{m}' for the requested protocol") |
Ready |
Call in Ready, allowed |
Send(method, next id) |
Ready, awaiting |
Received ok for a call |
Completed(value) |
Ready |
Received RPC error for a call |
Rejected(RPC error text) |
Ready |
Received invalid envelope for a call |
Failed |
Failed |
Close in Ready |
Send(shutdown, next id) |
ShuttingDown |
Received ok for shutdown |
ShutDown |
Stopped |
Received error for shutdown |
Failed |
Failed |
Received with nothing in flight |
Failed |
Failed |
Call in any other state |
Rejected(State) |
unchanged |
TransportFailed |
Failed(State) |
Failed |
- Step 1: Write the failing tests
crates/morphir-host/src/session_core.rs:
//! Sans-IO MEP session state machine.
#[cfg(test)]
mod tests {
use super::*;
use morphir_extension_sdk::protocol::{ExtensionResponse, PeerInfo, RpcError, methods};
use morphir_extension_sdk::{
ExtensionCapabilities, ExtensionInfo, ExtensionType, FrontendCapability,
};
use serde_json::json;
fn params() -> InitializeParams {
InitializeParams {
protocol_versions: vec!["0.1".into()],
host: PeerInfo {
name: "test".into(),
version: "1.0.0".into(),
},
}
}
fn frontend_result(protocol_version: &str) -> InitializeResult {
InitializeResult {
protocol_version: protocol_version.into(),
extension: ExtensionInfo {
id: "guest".into(),
name: "Guest".into(),
types: vec![ExtensionType::Frontend],
..ExtensionInfo::default()
},
capabilities: ExtensionCapabilities {
frontend: Some(FrontendCapability {
compile: true,
..FrontendCapability::default()
}),
..ExtensionCapabilities::default()
},
}
}
fn ok(id: u64, value: impl serde::Serialize) -> Event {
Event::Received(ExtensionResponse::success(id, value).unwrap())
}
fn rpc_error(id: u64, code: i32, message: &str) -> Event {
Event::Received(ExtensionResponse::error(
id,
RpcError {
code,
message: message.into(),
data: None,
},
))
}
fn sent(action: Action<HostError>) -> ExtensionRequest {
match action {
Action::Send(request) => request,
other => panic!("expected a request to send, got {other:?}"),
}
}
fn rejected(action: Action<HostError>) -> String {
match action {
Action::Rejected(error) => error.to_string(),
other => panic!("expected a rejection, got {other:?}"),
}
}
fn failed(action: Action<HostError>) -> String {
match action {
Action::Failed(error) => error.to_string(),
other => panic!("expected a failure, got {other:?}"),
}
}
fn ready_core() -> SessionCore<BasicChecks> {
let mut core = SessionCore::new(BasicChecks::new("guest"));
sent(core.handle(Event::Open(params())));
assert!(matches!(core.handle(ok(1, frontend_result("0.1"))), Action::Ready));
core
}
fn compile() -> Event {
Event::Call {
method: methods::COMPILE.into(),
params: json!({}),
}
}
#[test]
fn open_sends_initialize_with_the_offered_versions() {
let mut core = SessionCore::new(BasicChecks::new("guest"));
let request = sent(core.handle(Event::Open(params())));
assert_eq!(request.method, methods::INITIALIZE);
assert_eq!(request.id, 1);
assert_eq!(request.params["protocolVersions"], json!(["0.1"]));
}
#[test]
fn a_valid_answer_makes_the_session_ready() {
let core = ready_core();
assert!(core.is_ready());
assert_eq!(core.negotiated().unwrap().extension().id, "guest");
}
#[test]
fn a_version_the_host_did_not_offer_fails_the_session() {
let mut core = SessionCore::new(BasicChecks::new("guest"));
sent(core.handle(Event::Open(params())));
assert_eq!(
failed(core.handle(ok(1, frontend_result("9.9")))),
"Extension selected protocol version '9.9' that the host did not offer"
);
assert!(!core.is_ready());
}
#[test]
fn a_changed_identity_fails_the_session() {
let mut core = SessionCore::new(BasicChecks::new("someone-else"));
sent(core.handle(Event::Open(params())));
assert_eq!(
failed(core.handle(ok(1, frontend_result("0.1")))),
"Extension identity changed during initialization: expected 'someone-else', initialized 'guest'"
);
}
#[test]
fn an_rpc_error_during_initialize_fails_the_session() {
let mut core = SessionCore::new(BasicChecks::new("guest"));
sent(core.handle(Event::Open(params())));
assert_eq!(
failed(core.handle(rpc_error(1, -32603, "boom"))),
"RPC error -32603: boom"
);
}
#[test]
fn calls_use_the_next_request_id_and_complete() {
let mut core = ready_core();
let request = sent(core.handle(compile()));
assert_eq!(request.method, methods::COMPILE);
assert_eq!(request.id, 2);
match core.handle(ok(2, json!({"answer": 42}))) {
Action::Completed(value) => assert_eq!(value, json!({"answer": 42})),
other => panic!("expected completion, got {other:?}"),
}
assert!(core.is_ready());
}
#[test]
fn a_guest_rpc_error_rejects_the_call_and_keeps_the_session() {
let mut core = ready_core();
sent(core.handle(compile()));
assert_eq!(
rejected(core.handle(rpc_error(2, -32001, "does not compile"))),
"RPC error -32001: does not compile"
);
assert!(core.is_ready());
assert_eq!(sent(core.handle(compile())).id, 3);
}
#[test]
fn an_invalid_envelope_fails_the_session() {
let mut core = ready_core();
sent(core.handle(compile()));
assert_eq!(
failed(core.handle(ok(99, json!({})))),
"Extension response ID 99 did not match request ID 2"
);
assert_eq!(
rejected(core.handle(compile())),
"Session cannot call while failed"
);
}
#[test]
fn lifecycle_methods_cannot_be_called_directly() {
let mut core = ready_core();
let action = core.handle(Event::Call {
method: methods::SHUTDOWN.into(),
params: json!({}),
});
assert_eq!(
rejected(action),
"Protocol lifecycle method 'morphir.shutdown' must use its dedicated session operation"
);
assert!(core.is_ready());
}
#[test]
fn an_unadvertised_capability_is_rejected_without_sending() {
let mut core = ready_core();
let action = core.handle(Event::Call {
method: methods::GENERATE.into(),
params: json!({}),
});
assert_eq!(
rejected(action),
"RPC error -32013: Extension does not support capability 'morphir.backend.generate'"
);
assert_eq!(sent(core.handle(compile())).id, 2);
}
#[test]
fn a_second_call_waits_for_the_first_answer() {
let mut core = ready_core();
sent(core.handle(compile()));
assert_eq!(
rejected(core.handle(compile())),
"Session cannot call while waiting for a response"
);
}
#[test]
fn close_sends_shutdown_and_reports_shut_down() {
let mut core = ready_core();
let request = sent(core.handle(Event::Close));
assert_eq!(request.method, methods::SHUTDOWN);
assert_eq!(request.id, 2);
assert!(matches!(core.handle(ok(2, json!({}))), Action::ShutDown));
assert!(!core.is_ready());
}
#[test]
fn a_response_with_no_request_in_flight_fails_the_session() {
let mut core = ready_core();
assert_eq!(
failed(core.handle(ok(5, json!({})))),
"Extension sent response ID 5 with no request in flight"
);
}
#[test]
fn a_transport_failure_ends_the_session() {
let mut core = ready_core();
sent(core.handle(compile()));
failed(core.handle(Event::TransportFailed));
assert!(!core.is_ready());
}
}Add to crates/morphir-host/src/lib.rs:
mod session_core;
pub use session_core::{Action, BasicChecks, Event, SessionChecks, SessionCore};- Step 2: Run the tests and confirm they fail
Run: cargo test -p morphir-host session_core
Expected: compile error cannot find type SessionCore in this scope.
- Step 3: Implement the core
Put this above the tests in crates/morphir-host/src/session_core.rs:
use crate::envelope::{EnvelopeError, validate_envelope};
use crate::{HostError, Negotiated};
use morphir_extension_sdk::protocol::{
ExtensionRequest, ExtensionResponse, InitializeParams, InitializeResult, error_codes, methods,
};
use serde_json::Value;
/// Negotiation rules that a client adds to the MEP handshake.
pub trait SessionChecks {
/// The error type the client reports. Core errors convert into it.
type Error: From<HostError>;
/// Validate the guest's answer to `morphir.initialize`.
fn negotiate(
&mut self,
offered: &[String],
result: InitializeResult,
) -> Result<Negotiated, Self::Error>;
}
/// The checks every host applies: an offered version and the expected identity.
#[derive(Debug, Clone)]
pub struct BasicChecks {
expected_id: String,
}
impl BasicChecks {
/// Expect the guest to identify itself as `expected_id`.
pub fn new(expected_id: impl Into<String>) -> Self {
Self {
expected_id: expected_id.into(),
}
}
}
impl SessionChecks for BasicChecks {
type Error = HostError;
fn negotiate(
&mut self,
offered: &[String],
result: InitializeResult,
) -> Result<Negotiated, HostError> {
if !offered.contains(&result.protocol_version) {
return Err(HostError::VersionNotOffered {
selected: result.protocol_version,
offered: offered.to_vec(),
});
}
if result.extension.id != self.expected_id {
return Err(HostError::Invalid(format!(
"Extension identity changed during initialization: expected '{}', initialized '{}'",
self.expected_id, result.extension.id
)));
}
Ok(Negotiated::new(
result.protocol_version,
result.extension,
result.capabilities,
false,
))
}
}
/// Something that happened to the session.
#[derive(Debug)]
pub enum Event {
/// Start the handshake with these parameters.
Open(InitializeParams),
/// Invoke one non-lifecycle method.
Call {
/// The MEP method name.
method: String,
/// The request parameters.
params: Value,
},
/// The guest answered the request in flight.
Received(ExtensionResponse),
/// Begin MEP shutdown.
Close,
/// The transport failed, so the session is over.
TransportFailed,
}
/// What the client must do next.
#[derive(Debug)]
pub enum Action<E> {
/// Deliver this request, then pass the answer back as [`Event::Received`].
Send(ExtensionRequest),
/// Negotiation succeeded. [`SessionCore::negotiated`] now returns the result.
Ready,
/// The call succeeded with this result.
Completed(Value),
/// The call did not succeed, and the session is still ready.
Rejected(E),
/// The session broke. Abort the transport.
Failed(E),
/// The guest acknowledged shutdown. Terminate the transport.
ShutDown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Purpose {
Initialize,
Call,
Shutdown,
}
#[derive(Debug)]
struct Pending {
id: u64,
purpose: Purpose,
}
#[derive(Debug)]
enum State {
Loaded,
Initializing { offered: Vec<String> },
Ready,
ShuttingDown,
Stopped,
Failed,
}
/// The MEP session state, driven by events and free of I/O.
///
/// One request is in flight at a time. The first request has id 1.
#[derive(Debug)]
pub struct SessionCore<C: SessionChecks> {
checks: C,
state: State,
negotiated: Option<Negotiated>,
next_id: u64,
pending: Option<Pending>,
}
impl<C: SessionChecks> SessionCore<C> {
/// A session that has not started the handshake.
pub fn new(checks: C) -> Self {
Self {
checks,
state: State::Loaded,
negotiated: None,
next_id: 1,
pending: None,
}
}
/// The negotiation result, once the handshake succeeded.
pub fn negotiated(&self) -> Option<&Negotiated> {
self.negotiated.as_ref()
}
/// Whether the session accepts a call now.
pub fn is_ready(&self) -> bool {
matches!(self.state, State::Ready) && self.pending.is_none()
}
/// Advance the session by one event.
pub fn handle(&mut self, event: Event) -> Action<C::Error> {
match event {
Event::Open(params) => self.open(params),
Event::Call { method, params } => self.call(method, params),
Event::Received(response) => self.received(response),
Event::Close => self.close(),
Event::TransportFailed => {
self.pending = None;
self.state = State::Failed;
Action::Failed(
HostError::State {
action: "continue",
state: "failed",
}
.into(),
)
}
}
}
fn open(&mut self, params: InitializeParams) -> Action<C::Error> {
if !matches!(self.state, State::Loaded) {
let error = self.wrong_state("open");
self.state = State::Failed;
return Action::Failed(error);
}
let offered = params.protocol_versions.clone();
match self.request(methods::INITIALIZE, params, Purpose::Initialize) {
Ok(request) => {
self.state = State::Initializing { offered };
Action::Send(request)
}
Err(error) => {
self.state = State::Failed;
Action::Failed(error)
}
}
}
fn call(&mut self, method: String, params: Value) -> Action<C::Error> {
if !self.is_ready() {
return Action::Rejected(self.wrong_state("call"));
}
if matches!(
method.as_str(),
methods::INITIALIZE | methods::SHUTDOWN | methods::EXIT
) {
return Action::Rejected(
HostError::Rejected(format!(
"Protocol lifecycle method '{method}' must use its dedicated session operation"
))
.into(),
);
}
let negotiated = self
.negotiated
.as_ref()
.expect("a ready session is negotiated");
if !negotiated.supports_method(&method) {
return Action::Rejected(
HostError::Rejected(format!(
"RPC error {}: Extension does not support capability '{method}'",
error_codes::CAPABILITY_UNAVAILABLE
))
.into(),
);
}
if !negotiated.supports_invocation(&method, ¶ms) {
return Action::Rejected(
HostError::Rejected(format!(
"RPC error {}: Extension does not support capability '{method}' for the requested protocol",
error_codes::CAPABILITY_UNAVAILABLE
))
.into(),
);
}
match self.request(&method, params, Purpose::Call) {
Ok(request) => Action::Send(request),
Err(error) => Action::Rejected(error),
}
}
fn close(&mut self) -> Action<C::Error> {
if !self.is_ready() {
let error = self.wrong_state("close");
self.state = State::Failed;
return Action::Failed(error);
}
match self.request(methods::SHUTDOWN, serde_json::json!({}), Purpose::Shutdown) {
Ok(request) => {
self.state = State::ShuttingDown;
Action::Send(request)
}
Err(error) => {
self.state = State::Failed;
Action::Failed(error)
}
}
}
fn received(&mut self, response: ExtensionResponse) -> Action<C::Error> {
let Some(pending) = self.pending.take() else {
self.state = State::Failed;
return Action::Failed(
HostError::Invalid(format!(
"Extension sent response ID {} with no request in flight",
response.id
))
.into(),
);
};
let outcome = validate_envelope(response, pending.id);
match pending.purpose {
Purpose::Initialize => self.initialized(outcome),
Purpose::Call => match outcome {
Ok(value) => Action::Completed(value),
Err(EnvelopeError::Rpc(error)) => Action::Rejected(error.into()),
Err(EnvelopeError::Invalid(error)) => {
self.state = State::Failed;
Action::Failed(error.into())
}
},
Purpose::Shutdown => match outcome {
Ok(_) => {
self.state = State::Stopped;
Action::ShutDown
}
Err(EnvelopeError::Rpc(error) | EnvelopeError::Invalid(error)) => {
self.state = State::Failed;
Action::Failed(error.into())
}
},
}
}
fn initialized(&mut self, outcome: Result<Value, EnvelopeError>) -> Action<C::Error> {
let State::Initializing { offered } = std::mem::replace(&mut self.state, State::Failed)
else {
return Action::Failed(
HostError::State {
action: "initialize",
state: "not initializing",
}
.into(),
);
};
let value = match outcome {
Ok(value) => value,
Err(EnvelopeError::Rpc(error) | EnvelopeError::Invalid(error)) => {
return Action::Failed(error.into());
}
};
let result: InitializeResult = match serde_json::from_value(value) {
Ok(result) => result,
Err(error) => return Action::Failed(HostError::Json(error).into()),
};
match self.checks.negotiate(&offered, result) {
Ok(negotiated) => {
self.negotiated = Some(negotiated);
self.state = State::Ready;
Action::Ready
}
Err(error) => Action::Failed(error),
}
}
fn request(
&mut self,
method: &str,
params: impl serde::Serialize,
purpose: Purpose,
) -> Result<ExtensionRequest, C::Error> {
let id = self.next_id;
let next = id.checked_add(1).ok_or_else(|| {
C::Error::from(HostError::Rejected(
"Extension request identifier overflowed".into(),
))
})?;
let request = ExtensionRequest::new(method, params, id)
.map_err(|error| C::Error::from(HostError::Json(error)))?;
self.next_id = next;
self.pending = Some(Pending { id, purpose });
Ok(request)
}
fn wrong_state(&self, action: &'static str) -> C::Error {
let state = match self.state {
State::Ready if self.pending.is_some() => "waiting for a response",
State::Loaded => "loaded",
State::Initializing { .. } => "initializing",
State::Ready => "ready",
State::ShuttingDown => "shutting down",
State::Stopped => "stopped",
State::Failed => "failed",
};
HostError::State { action, state }.into()
}
}- Step 4: Run the tests
Run: cargo test -p morphir-host
Expected: 23 passed (9 from earlier tasks and 14 core tests).
- Step 5: Lint, wasm32 check, commit
Run: cargo clippy -p morphir-host --all-targets -- -D warnings && cargo check -p morphir-host --target wasm32-unknown-unknown
Expected: no warnings, Finished.
git add crates/morphir-host
git commit -m "feat(host): add the sans-IO MEP session core"
Task 4: Run the daemon's typestate session on
|
Task 5:
|
Task 6:
|
Task 7: One host identity and version list in the CLIStatus: Done in #982 (merged as This task runs in finos/morphir, at the umbrella root. Files:
Interfaces:
git switch -c feat/cli-host-config origin/main
git -C ecosystem/morphir-rust fetch origin
git -C ecosystem/morphir-rust checkout <merge-commit-sha-of-the-task-6-PR>
git add ecosystem/morphir-rustIn morphir-host = { path = "../../ecosystem/morphir-rust/crates/morphir-host" }
In #[test]
fn every_extension_call_offers_the_same_host_and_versions() {
let params = super::host_config().initialize_params();
assert_eq!(params.host.name, "morphir-cli");
assert_eq!(params.host.version, env!("CARGO_PKG_VERSION"));
let supported: Vec<String> = morphir_extension_sdk::protocol::SUPPORTED_MEP_VERSIONS
.iter()
.map(|version| (*version).to_owned())
.collect();
assert_eq!(params.protocol_versions, supported);
}Run:
In /// How this CLI introduces itself to extensions, and the MEP versions it offers.
///
/// Publish, install, compile, generate, and the workbench all use it, so an
/// extension sees the same host at every step.
pub(crate) fn host_config() -> morphir_host::HostConfig {
morphir_host::HostConfig::new(morphir_extension_sdk::protocol::PeerInfo {
name: "morphir-cli".into(),
version: env!("CARGO_PKG_VERSION").into(),
})
}In let description = transport
.describe(host_config().initialize_params())
.await
.map_err(|error| invalid(format!("Failed to describe process: {error}")))?;In let description = transport
.describe(super::host_config().initialize_params())
.await
.map_err(|error| DistributionError::Probe(error.to_string()))?;Remove the now unused In .initialize(crate::commands::extension::host_config().initialize_params())At line 15, change the import to drop the names that are no longer used: use morphir_extension_sdk::protocol::PeerInfo;If In let ready = loaded
.initialize(crate::commands::extension::host_config().initialize_params())
.await
.map_err(|failure| extension_error(failure.error().to_string()))?;Remove
Run: Run:
Run: Run: Run the host compatibility suite against the published extensions: mise run ci:fetch-published-bundles
cargo build --locked --release --package morphir
mise run test:host-compatibilityExpected: all six tests pass:
git add crates/morphir ecosystem/morphir-rust Cargo.lock
git commit -m "feat(cli): offer one host identity and MEP version list to every extension"
git push -u origin feat/cli-host-config
gh pr create --repo finos/morphir --base main --head feat/cli-host-config \
--title "feat(cli): offer one host identity and MEP version list to every extension" \
--body "Pins finos/morphir-rust to the morphir-host core and replaces host_peer and host_initialize_params with one host_config(). Compile, generate, install probe, publish describe, and the workbench now offer the same peer and SUPPORTED_MEP_VERSIONS.
Part of #963 and #959. Epic: #958." |
After part 1Write the part 2 plan when both PRs have merged. Start it from these facts:
|
Part 1 shipped
Changes to the plan made during execution
Other process and changes:
Part 2 gets its own plan and discussion. |
|
This plan is complete. Further tracking of the host library work is in beads ( |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
morphir-host Phase A, part 1: portable session core Implementation Plan
This discussion is the implementation plan for Phase A, part 1 of the host library work. The design is in #949 (checkpoints 1 to 17). Tracking: epic #958, Phase A #959.
The body holds the overview. Each task is a separate comment below, in order, so one agent can take one task. A task comment has its files, interfaces, test code, implementation code, commands, and expected output.
Goal: Create the portable
morphir-hostcrate with a sans-IO MEP session core. Run the daemon's existing session controller on top of that core without changing its behaviour. Make every CLI path offer one host identity and one version list.Architecture:
morphir-hostholds the MEP handshake as a synchronous state machine (SessionCore): an event goes in and one action comes out. Two async shells drive the core. The first is the existing typestateSession<T, S>inmorphir-daemon, which keeps its public API, so the CLI and the daemon's 60+ session tests become the regression suite for the core. The second is a new portableJsonRpcConnectionover aChanneltrait. It has no tokio code and compiles forwasm32-unknown-unknown. The CLI then takes its peer name and version list from oneHostConfig.Tech Stack: Rust 2024 (workspace
rust-version = "1.98"),serde/serde_json,thiserror2,async-trait0.1,tokio(tests only inmorphir-host),morphir-extension-sdk,morphir-workspace.Spec: the design record is #949 (checkpoints 1 to 17). It is also in the local notes:
.dev/docs/superpowers/specs/2026-09-23-morphir-host-library-design.md. Tracking: epic #958, Phase A #959. This plan covers finos/morphir-rust#236, part of finos/morphir-rust#237, and the host-identity part of #963.Status: All tasks done. See the closing checkpoint comment.
Scope of this plan
Phase A is too large for one plan. This plan is part 1. Part 1 alone produces software that works and is tested.
In part 1:
morphir-hostcrate withHostConfig,HostError,Negotiated, envelope validation,SessionChecks,SessionCore,Channel,GuestConnection,JsonRpcConnection,Session, the one-shot helpers, and aMemoryChanneltest double.morphir-daemon's typestate controller runs onSessionCore. Its public API does not change.host_config()for everyinitializeanddescribe.Part 2 gets its own plan after part 1 merges:
morphir-daemonand behindChannel(re-install npm #240).describewith session fallback (the install probe) intomorphir-host.RegistryandGuestSource, and the move of provider resolution out ofExtensionRegistry(CLI Structure #238).morphir-hostsessions (the rest of feat(cli): run compile, generate and probe through morphir-host #963).Pool, and the workbench leavessessions.rs(CLI Structure #239).Decisions this plan makes inside the spec
Check these during review. Each one narrows the spec. None of them changes a checkpoint.
SessionCore::handlereturns oneAction, not aVec<Action>. MEP sessions have one request in flight, so every event produces exactly one action.GuestConnectionhas a genericcall(method, params).Sessionprovides the typedcompileandgenerate. A laterComponentConnectionmaps method names to WIT exports.HostServicesis not in part 1. No MEP guest calls the host over JSON-RPC today, so aHostServicestrait would have no caller. It arrives with the component imports in Phase D.morphir.initializedin a normal session. The daemon does not send it today, and released guests were tested without it. Only the describe fallback sends it, and that code stays in the daemon in part 1.morphir-daemon. It depends onmorphir-coreandmorphir-distribution, which are not proven portable.SessionCorevalidates the envelope and the negotiation only.Global Constraints
ecosystem/morphir-rust/crates/morphir-host(finos/morphir-rust). Workspace members arecrates/*, so no workspace edit is needed.morphir-hostmust passcargo check -p morphir-host --target wasm32-unknown-unknown. It must not depend on tokio (except as a dev-dependency),morphir-daemon,morphir-distribution,morphir-core, or any language binding.MaybeSend(it meansSendon native targets and nothing onwasm32), with#[cfg_attr(not(target_arch = "wasm32"), async_trait)]and#[cfg_attr(target_arch = "wasm32", async_trait(?Send))].SUPPORTED_MEP_VERSIONSfrommorphir-extension-sdk(currently["0.1"]). Never hardcode"0.1"outside tests."morphir-cli"literal incrates/morphir/srcafter task 7 is the one inhost_config().RPC error {code}: {message},Extension selected protocol version '{v}' that the host did not offer,Extension response ID {a} did not match request ID {b}.morphir.initialize(id 1), calls (ids 2, 3, ...),morphir.shutdown, thenmorphir.exitfrom the transport.Co-Authored-Bylines (EasyCLA).Review Focus
These failure modes follow from the spec, but ordinary unit tests would miss them. Each line names the task whose test pins it.
morphir.initialized, or ids that no longer start at 1. Elm 0.3.1, scala-elm M08, and the published WASM backends would then break. Pinned by the task 4 testa_session_numbers_requests_in_the_released_orderand by the host compatibility suite in task 7.a_guest_rpc_error_rejects_the_call_and_keeps_the_sessionand the daemon actor testa_rejected_invocation_keeps_the_session_usable.Indeterminatemust not becomeStopped, and a failed channel must not be closed a second time. Pinned by the daemon testretains_an_indeterminate_state_after_an_uncertain_exchange_failure(task 4) and the task 5 testa_transport_failure_does_not_close_the_channel_again.morphir-host. The browser build then breaks without anyone noticing. Pinned by the CI wasm32 check added in task 1.File map
finos/morphir-rust (
ecosystem/morphir-rust/):crates/morphir-host/Cargo.tomlcrates/morphir-host/src/lib.rscrates/morphir-host/src/send.rsMaybeSendcrates/morphir-host/src/error.rsHostError,ChannelStatecrates/morphir-host/src/config.rsHostConfigcrates/morphir-host/src/negotiated.rsNegotiated(moved from the daemon'sNegotiatedSession)crates/morphir-host/src/envelope.rsvalidate_envelope,EnvelopeError(moved from the daemon'svalidate_response)crates/morphir-host/src/session_core.rsSessionChecks,BasicChecks,Event,Action,SessionCorecrates/morphir-host/src/channel.rsChannel,Outgoing,ChannelErrorcrates/morphir-host/src/connection.rsGuestConnection,CallErrorcrates/morphir-host/src/jsonrpc.rsJsonRpcConnectioncrates/morphir-host/src/session.rsSession,call_once,compile_once,generate_oncecrates/morphir-host/src/testing.rsMemoryChannel,SentLog,frontend_initialize_resultcrates/morphir-host/tests/connection.rsJsonRpcConnectionoverMemoryChannelcrates/morphir-host/tests/session.rsSessionand one-shot helperscrates/morphir-daemon/Cargo.tomlmorphir-hostcrates/morphir-daemon/src/error.rsFrom<HostError> for DaemonErrorcrates/morphir-daemon/src/extensions/session/controller.rsSessionCorecrates/morphir-daemon/src/extensions/session/validation.rsDaemonChecks; removevalidate_response,ResponseFailurecrates/morphir-daemon/src/extensions/session/tests.rsRequestLog::ids.github/workflows/ci.yml,.github/ci-impact.tomlfinos/morphir (umbrella root):
crates/morphir/Cargo.tomlmorphir-hostcrates/morphir/src/commands/extension.rshost_config()replaceshost_peer()crates/morphir/src/extensions.rshost_initialize_paramscrates/morphir/src/commands/extension/probe.rshost_config()crates/morphir/src/commands/ui/provider/extension.rshost_config()ecosystem/morphir-rustBranches and pull requests
cargocommand for tasks 1 to 6 fromecosystem/morphir-rust. Open one PR to finos/morphir-rust after task 6. It closes CLI structure #236 and refers to Adding badge for LCR interactive #237.Tasks
morphir-hostcrate withHostConfigandHostError(done)NegotiatedSessionand envelope validation intomorphir-host(done)SessionCore, the sans-IO MEP state machine (done)SessionCore(done)Channel,GuestConnectionandJsonRpcConnection(done)Sessionand the one-shot helpers (done)All reactions