Skip to content
Merged
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
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,27 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [Unreleased]

### Changed

- **A join that needs a persona can make one without leaving the join.** The
vetting route was blocked with "create one under My Identity", which meant
leaving the flow, finding the right pane, minting, and coming back to start
the join again — entering the community's DID a second time on the way. `n` on
that page now opens the same create-persona overlay the main page opens, with
every option it offers: label, whether the host names the DID's path or you
do, and which context it is minted into.

It is the same overlay, not a second one. Its keys and its rendering moved to
one module both pages call, so the phases and the wording cannot fork; only
the mint differs, because the runtime loop spawns it through its dispatcher
and the join loop awaits it inline — raced against the interrupt, with its
progress streaming onto the overlay as it arrives.

A persona minted this way is persisted and brought online before the overlay
closes: the route it unblocks is the one that would otherwise fail on a
persona that cannot send. The routes are re-derived once the overlay is gone,
so the row that was blocked on having no persona unblocks itself.

### Fixed

- **Pasting the community's DID on the join entry page works.** `[Ctrl+V]` read
Expand Down
161 changes: 158 additions & 3 deletions openvtc/src/state_handler/join_flow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,7 @@ fn build_routes(
"no application yet".to_string(),
Some(
"applying needs a persona — every card is signed by the DID you join with. \
Create one under My Identity."
Press n to create one without leaving this join."
.to_string(),
),
),
Expand Down Expand Up @@ -593,8 +593,8 @@ fn apply_for_vetting(
}
let Some(persona) = known.personas.get(known.persona_index).cloned() else {
return Err(
"Applying needs a persona: every card is signed by the DID you join with. Create one \
under My Identity, or join anyway (j), which makes one."
"Applying needs a persona: every card is signed by the DID you join with. Press n to \
create one here, or join anyway (j), which makes one."
.to_string(),
);
};
Expand Down Expand Up @@ -1188,6 +1188,53 @@ impl StateHandler {
return Ok(JoinExit::Exit(interrupted));
}
}
// The create-persona overlay, hosted here rather than
// on the main page. Everything except the mint is the
// shared pure reducer, so the phases and keys cannot
// drift from the ones the main page shows.
action if super::handle_nav_action(state, &action) => {
// A persona minted behind this page changes what the
// routes say, so they are re-derived once the overlay
// is gone. Cheap, idempotent, and it means the row
// that was blocked on having no persona unblocks
// itself rather than waiting for a re-entry.
if state.join.page == JoinPage::Vetting
&& state.main_page.create_persona.is_none()
&& let Some(vtc_did) = state.join.pending_vtc.clone()
{
show_vetting(state, config, &vtc_did);
}
}
Action::CreatePersonaSubmit => {
if let Some(interrupted) = self
.mint_persona_inline(
interrupt_rx,
state,
tdk,
config,
admin_vta,
profile,
messaging,
)
.await
{
return Ok(JoinExit::Exit(interrupted));
}
}
Action::CreatePersonaCopy => {
if let Some(did) = state
.main_page
.create_persona
.as_ref()
.and_then(|o| o.did.clone())
{
let copied =
crate::clipboard::copy_to_clipboard(&did).is_ok();
if let Some(o) = state.main_page.create_persona.as_mut() {
o.copied = copied;
}
}
}
Action::JoinVettingAskAgain => {
let Some(vtc_did) = state.join.pending_vtc.clone() else {
continue;
Expand Down Expand Up @@ -1310,6 +1357,99 @@ impl StateHandler {
}
}

/// Mint a persona from the overlay this flow is hosting, without leaving it.
///
/// The runtime loop spawns this job through its dispatcher; this loop has
/// none, so it awaits it the way it already awaits the join sequence —
/// raced against the interrupt, so a mint that hangs does not take Ctrl-C
/// with it (R15). Progress lines stream onto the overlay as they arrive
/// rather than after, because minting a DID is not instant and a frozen
/// "Creating persona…" is indistinguishable from a wedged one.
///
/// On success the persona is persisted and brought online here: a persona
/// the join can see but cannot send as would be worse than none, since the
/// route it unblocks is the one that then fails.
#[allow(clippy::too_many_arguments)]
async fn mint_persona_inline(
&self,
interrupt_rx: &mut broadcast::Receiver<Interrupted>,
state: &mut State,
tdk: &TDK,
config: &mut Config,
admin_vta: Option<&VtaClient>,
profile: &str,
messaging: Option<&Messaging>,
) -> Option<Interrupted> {
use crate::state_handler::background_dispatch::{DispatchOutcome, ProgressUpdate};

let (progress_tx, mut progress_rx) = tokio::sync::mpsc::unbounded_channel();
let Some(job) = super::advance_persona_overlay(state, config, tdk, admin_vta, &progress_tx)
else {
// A phase moved on, or refused and said why on the overlay.
let _ = self.state_tx.send(state.clone());
return None;
};
drop(progress_tx);
let _ = self.state_tx.send(state.clone());

let run = job.run();
tokio::pin!(run);
let outcome = loop {
tokio::select! {
outcome = &mut run => break outcome,
Some(update) = progress_rx.recv() => {
if let DispatchOutcome::Progress(ProgressUpdate::PersonaMint(line)) = update
&& let Some(o) = state.main_page.create_persona.as_mut()
{
o.messages.push(line);
let _ = self.state_tx.send(state.clone());
}
}
Ok(interrupted) = interrupt_rx.recv() => return Some(interrupted),
}
};

if let Some(minted) = outcome.apply(state) {
let did = minted.did.clone();
match minted.persist(config, tdk, profile).await {
Ok(persona_id) => {
let copied = crate::clipboard::copy_to_clipboard(&did).is_ok();
if let Some(o) = state.main_page.create_persona.as_mut() {
o.phase =
crate::state_handler::main_page::content::CreatePersonaPhase::Done;
o.did = Some(did.clone());
o.copied = copied;
o.messages.push("Persona created.".to_string());
}
state.main_page.sync_from_config(config);
state.main_page.log(format!("Created persona DID {did}"));
// Bring it online now. The join is about to offer it as a
// way in, and a persona that cannot send is not one. A
// no-op in State A, which has no service to install into —
// that loop restarts into the full pipeline after a join
// and brings it up there.
start_persona_listener(self, state, messaging, config, tdk, persona_id, &did)
.await;
}
Err(e) => {
// The DID exists at the VTA but is not in the config. Say so
// plainly: it is not lost, and a retry would mint a second.
if let Some(o) = state.main_page.create_persona.as_mut() {
o.phase =
crate::state_handler::main_page::content::CreatePersonaPhase::Failed;
o.messages
.push(format!("Minted, but could not be saved: {e}"));
}
state
.main_page
.log_error("Persona minted but not saved", &e);
}
}
}
let _ = self.state_tx.send(state.clone());
None
}

/// Go on with a join from the vetting page, presenting the statements of an
/// application that already meets the requirements when there is one.
///
Expand Down Expand Up @@ -3561,6 +3701,21 @@ mod vetting_tests {
);
}

/// The blocked reason names the key that is on the very page it appears on.
/// It used to send people to My Identity, which meant leaving the join and
/// entering the community's DID again on the way back.
#[test]
fn applying_without_a_persona_points_at_the_key_here() {
let routes = build_routes("Kernel", &requirements(None), None, &[], &[]);
let vetting = option_for(&routes, JoinRoute::Vetting).unwrap();
let why = vetting.blocked.as_deref().unwrap();
assert!(why.contains("Press n"), "{why}");
assert!(
!why.contains("My Identity"),
"the join no longer sends people away for this: {why}"
);
}

/// Statements ride with an open request whenever the joining persona has
/// gathered any, so the row must not read as a way to hold them back.
#[test]
Expand Down
65 changes: 43 additions & 22 deletions openvtc/src/state_handler/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3019,19 +3019,52 @@ fn spawn_persona_mint(
tdk: &TDK,
admin_vta: Option<&vta_sdk::client::VtaClient>,
) {
let Some(job) = advance_persona_overlay(state, config, tdk, admin_vta, dispatch_tx) else {
return;
};
let domain = background_dispatch::DispatchDomain::Persona;
if !in_flight.try_begin(domain) {
if let Some(o) = state.main_page.create_persona.as_mut() {
o.phase = main_page::content::CreatePersonaPhase::Path;
o.messages = vec![background_dispatch::InFlight::busy_message(domain)];
}
return;
}
background_dispatch::spawn_dispatch(dispatch_tx.clone(), domain, async move {
background_dispatch::DispatchOutcome::Persona(Box::new(job.run().await))
});
}

/// Advance the create-persona overlay one phase, and return the mint to run
/// when the last one commits.
///
/// Split out of [`spawn_persona_mint`] so the join flow can host the same
/// overlay. Everything up to the mint is phase-checking against `config` and
/// needs no I/O; the mint itself is a job, which the runtime loop spawns
/// through its dispatcher and the join loop awaits inline — the way that loop
/// already runs the join sequence. `None` means the overlay moved on (or
/// refused) and there is nothing to run.
pub(crate) fn advance_persona_overlay(
state: &mut State,
config: &Config,
tdk: &TDK,
admin_vta: Option<&vta_sdk::client::VtaClient>,
progress_tx: &tokio::sync::mpsc::UnboundedSender<background_dispatch::DispatchOutcome>,
) -> Option<create_persona::MintJob> {
use main_page::content::CreatePersonaPhase;

fn fail(state: &mut State, msg: &str, terminal: bool) {
/// Report onto the overlay and yield "nothing to run", so the callers
/// below can stay `return fail(…)`.
fn fail(state: &mut State, msg: &str, terminal: bool) -> Option<create_persona::MintJob> {
if let Some(o) = state.main_page.create_persona.as_mut() {
if terminal {
o.phase = CreatePersonaPhase::Failed;
}
o.messages = vec![msg.to_string()];
}
None
}
let Some(overlay) = state.main_page.create_persona.clone() else {
return;
};
let overlay = state.main_page.create_persona.clone()?;
let label = overlay.label.value().trim().to_string();
match overlay.phase {
// The label is checked, then where the DID sits on the host is asked —
Expand All @@ -3051,7 +3084,7 @@ fn spawn_persona_mint(
o.messages.clear();
o.phase = CreatePersonaPhase::Path;
}
return;
return None;
}
// The path is checked against the hosting server's naming rules before
// the contexts are offered, so a typo is caught here rather than after
Expand All @@ -3068,11 +3101,11 @@ fn spawn_persona_mint(
o.messages.clear();
o.phase = CreatePersonaPhase::Context;
}
return;
return None;
}
CreatePersonaPhase::Context => {}
CreatePersonaPhase::Working | CreatePersonaPhase::Done | CreatePersonaPhase::Failed => {
return;
return None;
}
}
// Re-checked rather than carried from the path phase: Esc goes back to it,
Expand All @@ -3097,32 +3130,20 @@ fn spawn_persona_mint(
true,
);
};
let domain = background_dispatch::DispatchDomain::Persona;
if !in_flight.try_begin(domain) {
return fail(
state,
&background_dispatch::InFlight::busy_message(domain),
false,
);
}

if let Some(o) = state.main_page.create_persona.as_mut() {
o.phase = CreatePersonaPhase::Working;
o.messages = vec![format!(
"Creating persona \u{201c}{label}\u{201d} in {context_id}\u{2026}"
)];
}

let job = create_persona::MintJob {
Some(create_persona::MintJob {
admin_vta: admin_vta.clone(),
tdk: tdk.clone(),
inputs: create_persona::MintInputs::from_config(config, context_id, path_mode),
label,
progress_tx: dispatch_tx.clone(),
};
background_dispatch::spawn_dispatch(dispatch_tx.clone(), domain, async move {
background_dispatch::DispatchOutcome::Persona(Box::new(job.run().await))
});
progress_tx: progress_tx.clone(),
})
}

/// Send a document addressed to a community, off the loop.
Expand Down
Loading
Loading