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
27 changes: 24 additions & 3 deletions polis-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -340,7 +340,22 @@ Bash, Zsh, Fish, PowerShell or Elvish scripts from the same command tree. See
topic, description, draft, public-data and strict-moderation settings, with explicit
booleans. It requires a complete moderator read, preserves five legacy defaulted
flags, and verifies the requested and preserved settings. There is no server
compare-and-set; coordinate concurrent edits. `conversation share ID` returns
compare-and-set; coordinate concurrent edits.

Admission settings change the same way after creation:
`--invite-only true|false` requires invitation membership, and
`--external-id optional|required|allowlist` uses the setup file's external-ID
model (an allow-list also requires an external ID). `allowlist` switches the
list check on without changing the listed IDs, which `conversation new` sets.
Node fixes the allow-list setting once a participant from outside the owner's
site has joined; a later change is refused with exit 5 (`xid_allowlist_fixed`)
and never retried.

```sh
polis conversation edit CONV --invite-only false --external-id required
```

`conversation share ID` returns
unverified links without a request or message. `conversation stats ID [--until MS]`
reads moderator event statistics, bounded to 8 MiB, without a report-ID fallback.

Expand Down Expand Up @@ -720,8 +735,14 @@ existing file.
Setup creates a staged conversation, confirms each settings/wave/allowlist/
statement step, and applies final participation settings last. On partial
failure it exits 7 with the known conversation ID and completed steps, without
retrying. Inspect that receipt before trying again: another `new` invocation
creates another conversation. Initial statements use moderator authority, with
retrying. The error says that the conversation exists, closed and invite-only,
and `data.finish` lists the commands that finish it with the requested
settings, for example `polis conversation edit ID --invite-only false
--external-id optional`, then `polis conversation reopen ID`. Inspect that
receipt before trying again: another `new` invocation creates another
conversation. If the site refuses the first create request, nothing was created
and the command exits with that refusal's own code (for example 4, forbidden).
Initial statements use moderator authority, with
no seed bypass flag or automatic vote. No invitation code is printed.

[The complete setting inventory and admission model](../docs/conversation-setup.md)
Expand Down
4 changes: 2 additions & 2 deletions polis-cli/crates/polis/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -323,12 +323,12 @@ fn leaf(path: &str) -> Command {
.arg(flag("token-stdin","Read an access token from stdin"))
.arg(flag("no-store","Validate stdin token without storing it").requires("token-stdin"))
.after_help("Browser login uses Authorization Code + S256 PKCE. --device signs in from a different browser without a callback listener. Both request offline_access. On macOS the access/refresh credentials are saved in the login Keychain; the fallback is an owner-only 0600 file under a 0700 directory, used when the Keychain is unavailable, with --no-interactive, with --with-token and on other platforms. Refresh is silent before expiry; revoked refresh credentials require a new sign-in. --no-interactive never shows browser or Keychain dialogs and still reads a Keychain sign-in when no dialog is needed; device authorization still needs approval on another device. auth token explicitly prints a secret. auth logout clears local credentials, not the provider browser session or server grant. POLIS_TOKEN overrides stored credentials (POLIS_TOKEN_<PROFILE> for developer profiles)."),
"conversation edit" => c.arg(pos("id",true)).arg(arg("topic","New topic, 1–1000 UTF-16 units; exact text preserved")).arg(arg("description","New description, up to 50000 UTF-16 units; empty string clears it")).arg(arg("draft","Draft state; explicit true or false").value_parser(["true","false"])).arg(arg("data-open","Public data setting; explicit true or false").value_parser(["true","false"])).arg(arg("strict-moderation","Require moderation; explicit true or false").value_parser(["true","false"])).after_help("EXAMPLES\n polis conversation edit CONV --topic 'Our shared priorities'\n polis conversation edit CONV --description '' --draft true --json\n\nRequires moderator access. Reads current settings to preserve the five flags that the legacy PUT defaults to false. Refuses incomplete settings before writing. No compare-and-set: coordinate concurrent moderator edits. Verifies requested and preserved flags; failed readback exits 7. No email, ID rotation or automatic retry."),
"conversation edit" => c.arg(pos("id",true)).arg(arg("topic","New topic, 1–1000 UTF-16 units; exact text preserved")).arg(arg("description","New description, up to 50000 UTF-16 units; empty string clears it")).arg(arg("draft","Draft state; explicit true or false").value_parser(["true","false"])).arg(arg("data-open","Public data setting; explicit true or false").value_parser(["true","false"])).arg(arg("strict-moderation","Require moderation; explicit true or false").value_parser(["true","false"])).arg(arg("invite-only","Require invitation membership; explicit true or false").value_parser(["true","false"])).arg(arg("external-id","External-ID check: optional, required (fresh identities need one) or allowlist (every join, vote and comment is checked against the list)").value_parser(polis_contracts::setup::ExternalIdentityMode::NAMES)).after_help("EXAMPLES\n polis conversation edit CONV --topic 'Our shared priorities'\n polis conversation edit CONV --description '' --draft true --json\n polis conversation edit CONV --invite-only false --external-id required\n\nRequires moderator access. Reads current settings to preserve the five flags that the legacy PUT defaults to false. Refuses incomplete settings before writing. No compare-and-set: coordinate concurrent moderator edits. Verifies requested and preserved flags; failed readback exits 7. No email, ID rotation or automatic retry.\n\nADMISSION\n --invite-only and --external-id change only what is given. --external-id allowlist switches the list check on; it does not change the listed IDs, which conversation new sets (participant allowlist import is planned). The allow-list setting is fixed once participation has started: Node refuses a change after a participant from outside the owner's site has joined (exit 5, xid_allowlist_fixed), and it is never retried. Read the setup admission model in docs/conversation-setup.md for which identities each check reaches."),
"conversation share" => c.arg(pos("id",true)).after_help("EXAMPLE\n polis conversation share CONV --json\n\nReturns participation_url and admin_share_url for the configured site. Links are unverified: this performs no HTTP request and changes no access. It never emails, opens a browser, creates an invitation or discloses a saved token."),
"conversation stats" => c.arg(pos("id",true)).arg(arg("until","Exclusive UTC epoch-millisecond cutoff, positive safe integer").value_parser(clap::value_parser!(u64).range(1..=9007199254740991))).after_help("EXAMPLES\n polis conversation stats CONV --json\n polis conversation stats CONV --until 1791417600000 --json\n\nRequires this site's moderator permission. Returns the existing vote/comment timing and histogram fields unchanged; these are historical event statistics, not unique latest votes or math freshness. No pagination; bounded to 8 MiB and the request timeout. No report-ID permission fallback."),
"report create" => c.arg(arg("conversation","Public conversation ID").required(true)).after_help("EXAMPLE\n polis report create --conversation CONV --json\n polis report list --conversation CONV --json\n\nRequires moderator access. Creates report configuration with the existing default mod_level=-2; starts no math, export or Delphi generation. The legacy POST has no created ID or idempotency key. data.report_id stays null; data.reports is an uncorrelated readback list, never proof of which report was created. A failed readback preserves the accepted receipt. Never repeat creation to recover a lost ID; list first. An uncertain write exits 7."),
"account view" => c.after_help("EXAMPLE\n polis account view --json\n\nRequires authentication. Returns this account's server profile, which may include personal data. It is not a plan, credit balance, or proof of permission for another resource."),
"conversation new" => c.arg(arg("from","Read a typed setup JSON file instead of asking questions")).arg(arg("save","Write the typed setup to a new private file")).arg(flag("dry-run","Describe setup without any HTTP requests")).after_help("EXAMPLES\n polis conversation new\n polis conversation new --from setup.json --dry-run --json\n polis conversation new --from setup.json --json\n\nUses the same typed model for people and agents. Invalid settings fail before writes. The interactive wizard shows a summary and asks before applying. Creates a staged closed conversation, applies settings, invitation waves, external-ID allowlist and initial statements, reads back each step, then applies the final participation settings. Partial failure exits 7 with completed steps; never blindly rerun new, which creates another conversation. Initial statements use the moderator bulk endpoint with no seed flag or automatic vote. No server permissions are changed. See docs/conversation-setup.md for current behavior and proposed improvements."),
"conversation new" => c.arg(arg("from","Read a typed setup JSON file instead of asking questions")).arg(arg("save","Write the typed setup to a new private file")).arg(flag("dry-run","Describe setup without any HTTP requests")).after_help("EXAMPLES\n polis conversation new\n polis conversation new --from setup.json --dry-run --json\n polis conversation new --from setup.json --json\n\nUses the same typed model for people and agents. Invalid settings fail before writes. The interactive wizard shows a summary and asks before applying. Creates a staged closed conversation, applies settings, invitation waves, external-ID allowlist and initial statements, reads back each step, then applies the final participation settings. Partial failure exits 7 with completed steps and, in data.finish, the commands that finish the staged closed, invite-only conversation; never blindly rerun new, which creates another conversation. A refused first create made nothing and exits with its own code. Initial statements use the moderator bulk endpoint with no seed flag or automatic vote. No server permissions are changed. See docs/conversation-setup.md for current behavior and proposed improvements."),
"conversation create" => c.arg(arg("topic","Conversation topic (1–1000 UTF-16 units)").required(true)).arg(arg("description","Optional description (up to 50000 UTF-16 units)")).arg(flag("draft","Create a draft")),
"conversation list" => c.arg(flag("mine","Owned conversations only (the default)")).arg(flag("recent","Newest created first (the default)")).arg(arg("limit","Rows read from the site before the owner filter (1–9999)").default_value("100").value_parser(clap::value_parser!(u16).range(1..=9999))).arg(arg("active","Filter active or closed").value_parser(["true","false"])).arg(arg("draft","Filter draft or published").value_parser(["true","false"])).arg(arg("search","Case-insensitive topic substring (literal), applied to the rows read")).after_help("EXAMPLES\n polis conversation list --json\n polis conversation list --limit 500 --active true --json\n polis conversation list --search 'budget' --json\n\nReads GET /api/v3/conversations. The current API returns an array of the conversations this account owns or shares a site with, newest created first, at most --limit rows; it has no offset, cursor, owner filter or search parameter. Rows whose server-set is_owner is false are dropped here, so pagination.returned can be below pagination.server_rows. pagination.truncated is true when the site returned --limit rows: raise --limit to read more. --active and --draft are applied by the site; --search is a literal, case-insensitive topic match applied to the rows read. Requires sign-in. Bounded reads, 8 MiB and --timeout; no automatic retry.\n\nWithout --json the result is a table: topic and description (cleaned and cut to fit the terminal), the conversation URL and the newest report URL, with +N when the conversation has N more reports. The report column comes from a second, best-effort GET /api/v3/reports: if that read fails, the table still prints with an empty column and one warning on stderr. --json reads only the conversations route and returns the data envelope unchanged."),
"conversation view" | "conversation close" | "conversation reopen" => c.arg(pos("id",true)),
Expand Down
3 changes: 2 additions & 1 deletion polis-cli/crates/polis/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -579,7 +579,8 @@ fn render(site: &str, result: Result<Value>, json_mode: bool) -> ExitCode {
let mut value = envelope(site, &result);
if setup_partial {
value["ok"] = json!(false);
value["error"] = json!({"code":"setup_incomplete","message":"Setup stopped before every step was confirmed.","action":"Inspect data.steps and data.failed_step before retrying. Rerunning new creates another conversation.","outcome":"unknown","retryable":false,"upstream":null});
// setup::apply says what the stopped setup left and how to finish it.
value["error"] = json!({"code":"setup_incomplete","message":value["data"]["message"],"action":value["data"]["action"],"outcome":"unknown","retryable":false,"upstream":null});
}
if partial {
value["ok"] = json!(false);
Expand Down
14 changes: 12 additions & 2 deletions polis-cli/crates/polis/src/pass5.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use crate::{client, profiles, public_id, str_arg};
use clap::ArgMatches;
use polis_contracts::{Error, Result};
use polis_contracts::{setup::ExternalIdentityMode, Error, Result};
use serde_json::{json, Map, Value};

// app.ts applies false defaults even to omitted fields on PUT conversations.
Expand Down Expand Up @@ -76,13 +76,23 @@ pub fn execute(
("draft", "is_draft"),
("data-open", "is_data_open"),
("strict-moderation", "strict_moderation"),
("invite-only", "treevite_enabled"),
] {
if let Some(value) = m.get_one::<String>(arg) {
changes.insert(field.into(), json!(value == "true"));
}
}
if let Some(name) = m.get_one::<String>("external-id") {
// Both Node flags come from the one typed mapping, never set apart.
let mode = ExternalIdentityMode::from_name(name).ok_or_else(|| {
Error::input("--external-id must be optional, required or allowlist.")
})?;
if let Value::Object(flags) = mode.node_flags() {
changes.extend(flags);
}
}
if changes.is_empty() {
return Err(Error::input("Choose at least one setting: --topic, --description, --draft, --data-open or --strict-moderation. Use --description '' to clear it."));
return Err(Error::input("Choose at least one setting: --topic, --description, --draft, --data-open, --strict-moderation, --invite-only or --external-id. Use --description '' to clear it."));
}
}
let c = client(config, site, timeout, true)?;
Expand Down
48 changes: 44 additions & 4 deletions polis-cli/crates/polis/src/setup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -522,11 +522,51 @@ fn apply(s: &ConversationSetup, c: &Client) -> Result<Value> {
Ok(()) => {
Ok(json!({"state":"created","conversation_id":id,"steps":steps,"summary":summary(s)}))
}
Err(e) => Ok(
json!({"state":"setup_partial","conversation_id":id,"steps":steps,"failed_step":step,
"error":safe(e),"action":"Inspect this conversation and the confirmed steps before retrying. No automatic retry was made; rerunning new creates another conversation."}),
),
// A refused create has no response to any step: nothing was created.
Err(e) if steps.is_empty() && e.outcome == "rejected" => Err(safe(e)),
Err(e) => {
let (message, action, finish) = left_behind(s, id.as_deref(), &step);
Ok(
json!({"state":"setup_partial","conversation_id":id,"steps":steps,"failed_step":step,
"error":safe(e),"message":message,"action":action,"finish":finish}),
)
}
}
}
/// What a stopped setup left on the site, and the commands that finish it with
/// the requested settings. Until the final step, the staged conversation is
/// closed and invite-only.
fn left_behind(
s: &ConversationSetup,
id: Option<&str>,
step: &str,
) -> (String, String, Vec<String>) {
let never = "Never rerun new: it creates another conversation. No automatic retry was made.";
let Some(id) = id else {
return (
format!("Setup stopped at {step} before the site confirmed a conversation ID. A conversation may exist, closed and invite-only."),
format!("{never} Run polis conversation list to look for it before doing anything else."),
vec![],
);
};
let state = if step.ends_with("finish") {
"may still be closed and invite-only"
} else {
"is closed and invite-only"
};
let mut finish = vec![format!(
"polis conversation edit {id} --invite-only {} --external-id {}",
matches!(s.join.invitations, Invitations::Required { .. }),
s.join.external_identity.mode().name()
)];
if s.activity == Activity::Open {
finish.push(format!("polis conversation reopen {id}"));
}
(
format!("Setup stopped at {step}. Conversation {id} exists and {state}."),
format!("{never} Inspect data.steps first: invitation waves, allow-list IDs and initial statements not confirmed there may be missing. Then finish it with: {}", finish.join(", then ")),
finish,
)
}

#[cfg(test)]
Expand Down
Loading
Loading