Files
tai/taicpp/src/mplex/mplex_plexer_flow.cpp
T
2026-10-25 06:48:34 +01:00

1050 lines
41 KiB
C++

// Port of tai.py:9302-9985: PlexerController family, plexer_show spec validation, PlexerFlow.
#include "mplex_plexer_flow.h"
#include <errno.h>
#include <stdlib.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <sys/time.h>
#include <sys/un.h>
#include <unistd.h>
#include <algorithm>
#include <cstdio>
#include <cstring>
#include <fstream>
#include <sstream>
#include <string_view>
#include "mplex_common.h"
#include "mplex_py.h"
namespace detz::mplex_flow {
namespace py = detz::mplex::py;
using detz::mplex::to_ustr;
using detz::mplex::UStr;
const std::vector<std::string> PLEXER_FLOW_TYPES = {"choice", "text", "info"};
const std::vector<std::string> PLEXER_FLOW_KINDS = {"message", "choice", "text"};
namespace {
// ---- Python value helpers ---------------------------------------------------------------
std::string repr_json(const Json& v) {
switch (v.kind()) {
case Json::Kind::Null: return "None";
case Json::Kind::Bool: return v.as_bool() ? "True" : "False";
case Json::Kind::Int: return std::to_string(v.as_int());
case Json::Kind::Float: return py::format_float(v.as_float());
case Json::Kind::String: return py::repr(v.as_string());
case Json::Kind::Array: {
std::string out = "[";
bool first = true;
for (const auto& item : v.as_array()) {
if (!first) out += ", ";
first = false;
out += repr_json(item);
}
return out + "]";
}
case Json::Kind::Object: {
std::string out = "{";
bool first = true;
for (const auto& item : v.as_object()) {
if (!first) out += ", ";
first = false;
out += py::repr(item.first) + ": " + repr_json(item.second);
}
return out + "}";
}
}
return "None";
}
// str(value)
std::string str_json(const Json& v) { return v.is_string() ? v.as_string() : repr_json(v); }
// "%d" in error texts
std::string d(long long n) { return std::to_string(n); }
bool is_scalar(const Json& v) { return v.is_string() || v.is_number() || v.is_bool() || v.is_null(); }
// int(value) with Python's TypeError/ValueError turned into nullopt.
std::optional<long long> py_int(const Json& v) {
switch (v.kind()) {
case Json::Kind::Bool: return v.as_bool() ? 1 : 0;
case Json::Kind::Int: return v.as_int();
case Json::Kind::Float: {
double f = v.as_float();
if (f != f || f > 9e18 || f < -9e18) return std::nullopt;
return static_cast<long long>(f);
}
case Json::Kind::String:
try {
return py::to_int(v.as_string());
} catch (const std::exception&) {
return std::nullopt;
}
default: return std::nullopt;
}
}
const Json null_json;
const Json& jget(const Json& obj, std::string_view key) {
const Json* hit = obj.get(std::string(key));
return hit ? *hit : null_json;
}
Json jget_or(const Json& obj, const std::string& key, const Json& fallback) {
const Json* hit = obj.get(key);
return hit ? *hit : fallback;
}
bool in_list(const std::vector<std::string>& list, const std::string& value) {
return std::find(list.begin(), list.end(), value) != list.end();
}
std::string errno_text(int err) {
return "[Errno " + std::to_string(err) + "] " + std::strerror(err);
}
std::string cap300(const std::string& text) { return py::slice(text, 0, 300); }
std::string universal_newlines(const std::string& raw) {
std::string out;
out.reserve(raw.size());
for (size_t i = 0; i < raw.size(); ++i) {
if (raw[i] == '\r') {
out.push_back('\n');
if (i + 1 < raw.size() && raw[i + 1] == '\n') ++i;
} else {
out.push_back(raw[i]);
}
}
return out;
}
std::string decode_replace(const std::string& raw) { return detz::mplex::to_utf8(to_ustr(raw)); }
// "text" % shlex-quoted arg, for the reader fragments: handles %% and a single %s.
std::string format_s(const std::string& fmt, const std::string& arg) {
std::string out;
for (size_t i = 0; i < fmt.size(); ++i) {
if (fmt[i] == '%' && i + 1 < fmt.size()) {
if (fmt[i + 1] == '%') {
out.push_back('%');
++i;
continue;
}
if (fmt[i + 1] == 's') {
out += arg;
++i;
continue;
}
}
out.push_back(fmt[i]);
}
return out;
}
std::string temp_dir() {
for (const char* name : {"TMPDIR", "TEMP", "TMP"}) {
const char* v = std::getenv(name);
if (v && *v) return v;
}
return "/tmp";
}
// tempfile.mkstemp(prefix="tai-flow-", suffix=...): returns (fd, path); fd -1 on failure.
std::pair<int, std::string> make_temp(const std::string& suffix) {
std::string pattern = temp_dir() + "/tai-flow-XXXXXX" + suffix;
std::vector<char> buf(pattern.begin(), pattern.end());
buf.push_back('\0');
int fd = ::mkstemps(buf.data(), static_cast<int>(suffix.size()));
return {fd, std::string(buf.data())};
}
} // namespace
std::string plexer_dir(const std::string& home) {
std::string path = home + "/" + PLEXER_DIR_NAME;
std::string partial;
size_t pos = 0;
while (pos <= path.size()) {
size_t next = path.find('/', pos);
if (next == std::string::npos) next = path.size();
partial = path.substr(0, next);
if (!partial.empty()) ::mkdir(partial.c_str(), 0777);
pos = next + 1;
}
::chmod(path.c_str(), 0700);
return path;
}
// ---- PlexerController ---------------------------------------------------------------------
void PlexerController::type_text(const std::string& text, double delay, bool enter) {
UStr chars = to_ustr(text);
for (wchar_t ch : chars) {
send_keys(detz::mplex::to_utf8(ch));
::usleep(static_cast<useconds_t>(delay * 1e6));
}
if (enter) send_keys("", true);
}
// ---- NativePlexerController -----------------------------------------------------------------
NativePlexerController::NativePlexerController(const std::string& socket_path_in, const std::optional<std::string>& pane_in)
: socket_path(socket_path_in), pane(pane_in) {
kind = "native";
}
Json NativePlexerController::call(const Json& payload_in) {
Json payload = payload_in;
if (pane && !pane->empty()) payload.set("pane", Json::string(*pane));
int sock = ::socket(AF_UNIX, SOCK_STREAM, 0);
struct timeval tv {10, 0};
if (sock >= 0) {
::setsockopt(sock, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof tv);
::setsockopt(sock, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof tv);
}
struct sockaddr_un addr {};
addr.sun_family = AF_UNIX;
int err = 0;
if (sock < 0) {
err = errno;
} else if (socket_path.size() >= sizeof(addr.sun_path)) {
err = ENAMETOOLONG;
} else {
std::memcpy(addr.sun_path, socket_path.c_str(), socket_path.size() + 1);
if (::connect(sock, reinterpret_cast<sockaddr*>(&addr), sizeof addr) != 0) err = errno;
}
if (err) {
if (sock >= 0) ::close(sock);
throw PlexerError("cannot reach native plexer backend: " + cap300(err == EAGAIN ? "timed out" : errno_text(err)));
}
std::string chunks;
try {
std::string line = payload.dumps() + "\n";
size_t done = 0;
while (done < line.size()) {
ssize_t n = ::send(sock, line.data() + done, line.size() - done, MSG_NOSIGNAL);
if (n < 0) {
if (errno == EINTR) continue;
int e = errno;
throw PlexerError("native plexer backend read/write failed: " +
cap300(e == EAGAIN ? "timed out" : errno_text(e)));
}
done += static_cast<size_t>(n);
}
for (;;) {
char chunk[65536];
ssize_t n = ::recv(sock, chunk, sizeof chunk, 0);
if (n < 0) {
if (errno == EINTR) continue;
int e = errno;
throw PlexerError("native plexer backend read/write failed: " +
cap300(e == EAGAIN ? "timed out" : errno_text(e)));
}
if (n == 0) break;
chunks.append(chunk, static_cast<size_t>(n));
if (chunk[n - 1] == '\n') break;
}
} catch (...) {
::close(sock);
throw;
}
::close(sock);
std::string raw = py::strip(decode_replace(chunks));
Json reply = Json::object();
if (!raw.empty()) {
try {
reply = Json::loads(raw);
} catch (const std::exception&) {
throw PlexerError("native plexer backend sent an unparseable reply");
}
}
if (!reply.is_object()) throw PlexerError("native plexer backend refused the command");
const Json* ok = reply.get("ok");
if (!(ok && ok->truthy())) {
const Json* error = reply.get("error");
if (error && error->truthy()) throw PlexerError(str_json(*error));
throw PlexerError("native plexer backend refused the command");
}
return jget(reply, "data");
}
Json NativePlexerController::send_keys(const std::string& text, bool enter) {
Json p = Json::object();
p.set("cmd", Json::string("send"));
p.set("text", Json::string(text));
p.set("enter", Json::boolean(enter));
return call(p);
}
Json NativePlexerController::read_pane(int lines) {
Json p = Json::object();
p.set("cmd", Json::string("read"));
p.set("lines", Json::integer(lines));
return call(p);
}
Json NativePlexerController::status() {
Json p = Json::object();
p.set("cmd", Json::string("status"));
return call(p);
}
Json NativePlexerController::ask(const Json& screen, double timeout) {
Json show = Json::object();
show.set("cmd", Json::string("show"));
show.set("screen", screen);
show.set("timeout", Json::number(timeout));
Json handle = call(show);
if (!handle.is_object() || !handle.has("id")) throw PlexerError("native plexer backend sent no flow id");
Json flow_id = jget(handle, "id");
double deadline = py::monotonic() + std::max(1.0, timeout);
for (;;) {
Json q = Json::object();
q.set("cmd", Json::string("result"));
q.set("id", flow_id);
Json reply = call(q);
const Json* done = reply.is_object() ? reply.get("done") : nullptr;
if (done && done->truthy()) {
const Json* status = reply.get("status");
std::string st = (status && status->is_string()) ? status->as_string() : "";
if (st == "ok") {
Json out = Json::object();
out.set("status", Json::string("ok"));
out.set("value", jget(reply, "value"));
return out;
}
if (st == "cancelled" || st == "timeout") {
Json out = Json::object();
out.set("status", Json::string(st));
return out;
}
std::string shown = (status && status->truthy()) ? str_json(*status) : "unknown";
throw PlexerError("flow " + str_json(flow_id) + " ended: " + shown);
}
if (py::monotonic() >= deadline) {
try {
Json c = Json::object();
c.set("cmd", Json::string("cancel"));
c.set("id", flow_id);
call(c);
} catch (const PlexerError&) {
}
Json out = Json::object();
out.set("status", Json::string("timeout"));
return out;
}
::usleep(static_cast<useconds_t>(PLEXER_FLOW_POLL * 1e6));
}
}
// ---- TmuxPlexerController ---------------------------------------------------------------------
TmuxPlexerController::TmuxPlexerController(const std::string& pane_in) : pane(pane_in) { kind = "tmux"; }
std::string TmuxPlexerController::tmux(const std::vector<std::string>& args) {
std::vector<std::string> argv = {"tmux"};
argv.insert(argv.end(), args.begin(), args.end());
py::RunResult done;
try {
done = py::run_argv(argv, true, true, 15);
} catch (const std::exception& exc) {
throw PlexerError("tmux call failed: " + cap300(exc.what()));
}
if (done.timed_out) {
std::string cmd = "[";
for (size_t i = 0; i < argv.size(); ++i) cmd += (i ? ", " : "") + py::repr(argv[i]);
cmd += "]";
throw PlexerError("tmux call failed: " + cap300("Command '" + cmd + "' timed out after 15 seconds"));
}
if (done.returncode != 0)
throw PlexerError("tmux error: " + py::slice(py::strip(universal_newlines(decode_replace(done.err))), 0, 300));
return universal_newlines(decode_replace(done.out));
}
Json TmuxPlexerController::send_keys(const std::string& text, bool enter) {
if (!text.empty()) tmux({"send-keys", "-t", pane, "-l", text});
if (enter) tmux({"send-keys", "-t", pane, "Enter"});
return Json::boolean(true);
}
Json TmuxPlexerController::read_pane(int lines) {
return Json::string(tmux({"capture-pane", "-p", "-t", pane, "-S", "-" + std::to_string(std::max(1, lines))}));
}
Json TmuxPlexerController::status() {
std::string info = tmux({"list-panes", "-t", pane, "-F", "#{session_name}:#{window_index}.#{pane_index} #{pane_current_command}"});
Json out = Json::object();
out.set("backend", Json::string("tmux"));
out.set("pane", Json::string(pane));
out.set("info", Json::string(py::strip(info)));
return out;
}
Json TmuxPlexerController::ask(const Json& screen, double timeout) {
const Json& kind_json = jget(screen, "kind");
std::string k = kind_json.is_string() ? kind_json.as_string() : "";
if (kind_json.is_string()) {
if (k == "choice") return ask_choice(screen, timeout);
if (k == "text") return ask_text(screen, timeout);
if (k == "message") return ask_message(screen, timeout);
}
throw PlexerError("unknown screen kind " + repr_json(kind_json));
}
std::string TmuxPlexerController::ask_result_path() {
auto made = make_temp(".answer");
if (made.first >= 0) ::close(made.first);
::unlink(made.second.c_str());
return made.second;
}
std::optional<std::string> TmuxPlexerController::await_answer(const std::string& path, double budget) {
double deadline = py::monotonic() + std::max(0.0, budget);
for (;;) {
std::ifstream in(path, std::ios::binary);
if (in) {
std::stringstream ss;
ss << in.rdbuf();
return decode_replace(ss.str());
}
if (py::monotonic() >= deadline) return std::nullopt;
::usleep(50000);
}
}
void TmuxPlexerController::ask_dismiss() {
try {
py::run_argv({"tmux", "display-popup", "-C"}, true, true, 5);
} catch (const std::exception&) {
}
}
TmuxPlexerController::RunOutcome TmuxPlexerController::ask_run(const std::vector<std::string>& argv, const std::string& path,
double timeout) {
std::vector<std::string> full = {"tmux"};
full.insert(full.end(), argv.begin(), argv.end());
py::RunResult done;
try {
done = py::run_argv(full, true, true, std::max(1.0, timeout));
} catch (const std::exception& exc) {
throw PlexerError("tmux call failed: " + cap300(exc.what()));
}
RunOutcome out;
if (done.timed_out) {
ask_dismiss();
out.timed_out = true;
out.content = "timeout";
return out;
}
std::string err = py::strip(universal_newlines(decode_replace(done.err)));
if (done.returncode != 0 && !err.empty())
throw PlexerError("tmux " + argv[0] + " failed: " + py::slice(err, 0, 300));
if (done.returncode != 0) return out;
out.content = await_answer(path);
return out;
}
Json TmuxPlexerController::ask_choice(const Json& screen, double timeout) {
const Json& opts_json = jget(screen, "options");
std::vector<std::string> options;
if (opts_json.truthy() && opts_json.is_array())
for (const auto& o : opts_json.as_array()) options.push_back(str_json(o));
std::string path = ask_result_path();
RunOutcome raw;
try {
const Json& title = jget(screen, "title");
std::vector<std::string> argv = {"display-menu", "-T", title.truthy() ? str_json(title) : "tai"};
static const std::string keys = "123456789abcdefghijklmnopqrstuvwxyz";
for (size_t index = 0; index < options.size(); ++index) {
if (index >= keys.size()) throw PlexerError("string index out of range");
argv.push_back(options[index]);
argv.push_back(std::string(1, keys[index]));
argv.push_back("run-shell \"printf '%s' '" + std::to_string(index) + "' > " + py::shlex_quote(path) + "\"");
}
raw = ask_run(argv, path, timeout);
} catch (...) {
::unlink(path.c_str());
throw;
}
::unlink(path.c_str());
Json out = Json::object();
if (raw.content && *raw.content == "timeout") {
out.set("status", Json::string("timeout"));
return out;
}
std::string stripped = raw.content ? py::strip(*raw.content) : "";
if (!raw.content || !py::all_digits(to_ustr(stripped))) {
out.set("status", Json::string("cancelled"));
return out;
}
long long index = py::to_int(stripped);
if (index >= 0 && index < static_cast<long long>(options.size())) {
out.set("status", Json::string("ok"));
out.set("value", Json::integer(index));
return out;
}
out.set("status", Json::string("cancelled"));
return out;
}
TmuxPlexerController::RunOutcome TmuxPlexerController::ask_popup(const std::string& title, const std::string& body,
const std::string& reader, double timeout) {
auto made = make_temp(".prompt");
if (made.first < 0) throw PlexerError("cannot stage prompt text: " + errno_text(errno));
std::string prompt_path = made.second;
{
size_t done = 0;
while (done < body.size()) {
ssize_t n = ::write(made.first, body.data() + done, body.size() - done);
if (n < 0) {
if (errno == EINTR) continue;
std::string text = errno_text(errno);
::close(made.first);
throw PlexerError("cannot stage prompt text: " + cap300(text));
}
done += static_cast<size_t>(n);
}
::close(made.first);
}
std::string path = ask_result_path();
RunOutcome raw;
try {
std::string command = "cat " + py::shlex_quote(prompt_path) + "; " + format_s(reader, py::shlex_quote(path));
raw = ask_run({"display-popup", "-w", "62", "-h", "10", "-T", title.empty() ? "tai" : title, "-E", command}, path, timeout);
} catch (...) {
::unlink(prompt_path.c_str());
::unlink(path.c_str());
throw;
}
::unlink(prompt_path.c_str());
::unlink(path.c_str());
return raw;
}
Json TmuxPlexerController::ask_text(const Json& screen, double timeout) {
const Json& text = jget(screen, "text");
std::string body = (text.truthy() ? str_json(text) : std::string()) + "\n\n> ";
const Json& title = jget(screen, "title");
RunOutcome raw = ask_popup(title.truthy() ? str_json(title) : "", body, "read -r TAI_V && printf '%%s' \"$TAI_V\" > %s", timeout);
Json out = Json::object();
if (raw.content && *raw.content == "timeout") {
out.set("status", Json::string("timeout"));
return out;
}
if (!raw.content) {
out.set("status", Json::string("cancelled"));
return out;
}
out.set("status", Json::string("ok"));
out.set("value", Json::string(py::slice(*raw.content, 0, PLEXER_FLOW_ANSWER_MAX)));
return out;
}
Json TmuxPlexerController::ask_message(const Json& screen, double timeout) {
const Json& text = jget(screen, "text");
std::string body = (text.truthy() ? str_json(text) : std::string()) + "\n\n[Enter] ";
const Json& title = jget(screen, "title");
RunOutcome raw = ask_popup(title.truthy() ? str_json(title) : "", body, "read -r _ && printf ok > %s", timeout);
Json out = Json::object();
if (raw.content && *raw.content == "timeout") {
out.set("status", Json::string("timeout"));
return out;
}
if (!raw.content) {
out.set("status", Json::string("cancelled"));
return out;
}
out.set("status", Json::string("ok"));
out.set("value", Json::boolean(true));
return out;
}
std::unique_ptr<PlexerController> PlexerControllerFactory::detect() {
const char* socket_path = std::getenv(PLEXER_SOCKET_ENV);
if (socket_path && *socket_path) {
const char* pane = std::getenv(PLEXER_PANE_ENV);
return std::make_unique<NativePlexerController>(socket_path, pane ? std::optional<std::string>(pane) : std::nullopt);
}
const char* pane = std::getenv("TMUX_PANE");
if (pane && *pane) return std::make_unique<TmuxPlexerController>(pane);
return nullptr;
}
// ---- validation --------------------------------------------------------------------------------
std::pair<std::string, std::string> validate_flow_id(const Json& raw, const std::string& what) {
std::string text = py::strip(raw.truthy() ? str_json(raw) : std::string());
if (text.empty()) return {"", what + " id must be a non-empty string"};
if (py::len(text) > static_cast<size_t>(PLEXER_FLOW_ID_MAX))
return {"", what + " id " + py::repr(py::slice(text, 0, 72)) + " is longer than " + d(PLEXER_FLOW_ID_MAX) + " chars"};
for (wchar_t ch : to_ustr(text))
if (py::isspace(ch)) return {"", what + " id " + py::repr(text) + " must not contain whitespace"};
return {text, ""};
}
std::pair<std::string, std::string> validate_flow_text(const Json& raw, const std::string& what, size_t limit) {
if (!raw.is_string()) return {"", what + " must be a string"};
if (py::len(raw.as_string()) > limit) return {"", what + " is longer than " + d(static_cast<long long>(limit)) + " chars"};
return {raw.as_string(), ""};
}
std::pair<Json, std::string> validate_flow_options(const Json& raw, const std::string& what) {
if (!raw.is_array() || raw.as_array().empty())
return {Json::array(), what + " needs 1 to " + d(PLEXER_FLOW_OPTIONS_MAX) + " options"};
if (raw.as_array().size() > static_cast<size_t>(PLEXER_FLOW_OPTIONS_MAX))
return {Json::array(), what + " has " + d(static_cast<long long>(raw.as_array().size())) + " options, at most " +
d(PLEXER_FLOW_OPTIONS_MAX)};
Json options = Json::array();
long long pos = 0;
for (const auto& item : raw.as_array()) {
Json label, value;
if (item.is_string()) {
label = item;
value = item;
} else if (item.is_object()) {
label = jget(item, "label");
value = jget_or(item, "value", jget(item, "label"));
} else {
return {Json::array(), what + " option " + d(pos + 1) + " must be a string or {label, value}"};
}
if (!label.is_string() || py::strip(label.as_string()).empty())
return {Json::array(), what + " option " + d(pos + 1) + " needs a non-empty label"};
if (py::len(label.as_string()) > static_cast<size_t>(PLEXER_FLOW_LABEL_MAX))
return {Json::array(), what + " option " + d(pos + 1) + " label is longer than " + d(PLEXER_FLOW_LABEL_MAX) + " chars"};
if (!is_scalar(value)) return {Json::array(), what + " option " + d(pos + 1) + " value must be a JSON scalar"};
Json entry = Json::object();
entry.set("label", label);
entry.set("value", value);
options.push(entry);
++pos;
}
return {options, ""};
}
std::pair<Json, std::string> validate_flow_condition(const Json& raw, const std::string& what,
const std::vector<std::string>& known) {
if (raw.is_null()) return {Json::object(), ""};
if (!raw.is_object() || raw.as_object().empty())
return {Json::object(), what + " show_if must be a non-empty {field id: expected} object"};
Json clauses = Json::object();
for (const auto& entry : raw.as_object()) {
const std::string& key = entry.first;
const Json& expected = entry.second;
if (!in_list(known, key)) return {Json::object(), what + " show_if refers to unknown field " + py::repr(key)};
Json accepted = expected.is_array() ? expected : Json::array({expected});
if (accepted.as_array().empty())
return {Json::object(), what + " show_if for " + py::repr(key) + " needs at least one expected value"};
for (const auto& value : accepted.as_array())
if (!is_scalar(value)) return {Json::object(), what + " show_if for " + py::repr(key) + " must hold JSON scalars"};
clauses.set(key, accepted);
}
return {clauses, ""};
}
namespace {
using Fail = std::pair<Json, std::string>;
Fail fail(const std::string& reason) { return {Json(), reason}; }
} // namespace
std::pair<Json, std::string> validate_flow_spec(const Json& spec) {
if (!spec.is_object()) return fail("spec must be a JSON object");
Json version = jget_or(spec, "v", Json::integer(PLEXER_FLOW_VERSION));
if (!version.equals(Json::integer(PLEXER_FLOW_VERSION)))
return fail("spec v must be " + d(PLEXER_FLOW_VERSION) + ", got " + repr_json(version));
auto [title, error] = validate_flow_text(jget_or(spec, "title", Json::string("")), "title", PLEXER_FLOW_TITLE_MAX);
if (!error.empty()) return fail(error);
auto timeout_opt = py_int(jget_or(spec, "timeout", Json::integer(PLEXER_FLOW_TIMEOUT_DEFAULT)));
if (!timeout_opt) return fail("timeout must be an integer number of seconds");
long long timeout = *timeout_opt;
if (!(PLEXER_FLOW_TIMEOUT_MIN <= timeout && timeout <= PLEXER_FLOW_TIMEOUT_MAX))
return fail("timeout must be " + d(PLEXER_FLOW_TIMEOUT_MIN) + " to " + d(PLEXER_FLOW_TIMEOUT_MAX) + " seconds");
const Json& raw_screens = jget(spec, "screens");
if (!raw_screens.is_array() || raw_screens.as_array().empty()) return fail("screens must be a non-empty list");
if (raw_screens.as_array().size() > static_cast<size_t>(PLEXER_FLOW_SCREENS_MAX))
return fail(d(static_cast<long long>(raw_screens.as_array().size())) + " screens, at most " + d(PLEXER_FLOW_SCREENS_MAX));
Json screens = Json::array();
std::vector<std::string> screen_ids, field_ids;
long long pos = 0;
for (const auto& item : raw_screens.as_array()) {
std::string what = "screen " + d(pos + 1);
++pos;
if (!item.is_object()) return fail(what + " must be an object");
auto [ident, e1] = validate_flow_id(jget(item, "id"), what);
if (!e1.empty()) return fail(e1);
if (in_list(screen_ids, ident)) return fail("duplicate screen id " + py::repr(ident));
screen_ids.push_back(ident);
auto [title_s, e2] = validate_flow_text(jget_or(item, "title", Json::string("")), ident + " title", PLEXER_FLOW_TITLE_MAX);
if (!e2.empty()) return fail(e2);
auto [body, e3] = validate_flow_text(jget_or(item, "body", Json::string("")), ident + " body", PLEXER_FLOW_TEXT_MAX);
if (!e3.empty()) return fail(e3);
Json raw_fields = jget_or(item, "fields", Json::array());
if (!raw_fields.is_array()) return fail(ident + " fields must be a list");
if (raw_fields.as_array().size() > static_cast<size_t>(PLEXER_FLOW_FIELDS_MAX))
return fail(ident + " has " + d(static_cast<long long>(raw_fields.as_array().size())) + " fields, at most " +
d(PLEXER_FLOW_FIELDS_MAX));
Json fields = Json::array();
long long fpos = 0;
for (const auto& field : raw_fields.as_array()) {
std::string fwhat = ident + " field " + d(fpos + 1);
++fpos;
if (!field.is_object()) return fail(fwhat + " must be an object");
Json kind = jget(field, "type");
if (!(kind.is_string() && in_list(PLEXER_FLOW_TYPES, kind.as_string()))) {
std::string listed;
for (size_t i = 0; i < PLEXER_FLOW_TYPES.size(); ++i) listed += (i ? "/" : "") + PLEXER_FLOW_TYPES[i];
return fail(fwhat + " type must be one of " + listed + ", got " + repr_json(kind));
}
auto [fid, e4] = validate_flow_id(jget(field, "id"), fwhat);
if (!e4.empty()) return fail(e4);
if (in_list(field_ids, fid)) return fail("duplicate field id " + py::repr(fid));
field_ids.push_back(fid);
auto [label, e5] = validate_flow_text(jget_or(field, "label", Json::string("")), fid + " label", PLEXER_FLOW_LABEL_MAX);
if (!e5.empty()) return fail(e5);
Json entry = Json::object();
entry.set("id", Json::string(fid));
entry.set("type", kind);
entry.set("label", Json::string(label));
entry.set("show_if", jget(field, "show_if"));
if (kind.as_string() == "choice") {
auto [options, e6] = validate_flow_options(jget(field, "options"), fid);
if (!e6.empty()) return fail(e6);
entry.set("options", options);
auto def = py_int(jget_or(field, "default", Json::integer(0)));
if (!def) return fail(fid + " default must be an option index");
long long count = static_cast<long long>(options.as_array().size());
if (!(0 <= *def && *def < count))
return fail(fid + " default " + d(*def) + " is outside its " + d(count) + " options");
entry.set("default", Json::integer(*def));
} else if (kind.as_string() == "text") {
auto [initial, e7] = validate_flow_text(jget_or(field, "initial", Json::string("")), fid + " initial", PLEXER_FLOW_ANSWER_MAX);
if (!e7.empty()) return fail(e7);
entry.set("initial", Json::string(initial));
} else {
auto [text, e8] = validate_flow_text(jget_or(field, "text", Json::string("")), fid + " text", PLEXER_FLOW_TEXT_MAX);
if (!e8.empty()) return fail(e8);
entry.set("text", Json::string(text));
}
fields.push(entry);
}
Json raw_actions = jget_or(item, "actions", Json::array());
if (!raw_actions.is_array()) return fail(ident + " actions must be a list");
if (raw_actions.as_array().size() > static_cast<size_t>(PLEXER_FLOW_ACTIONS_MAX))
return fail(ident + " has " + d(static_cast<long long>(raw_actions.as_array().size())) + " actions, at most " +
d(PLEXER_FLOW_ACTIONS_MAX));
Json actions = Json::array();
std::vector<std::string> action_ids;
long long apos = 0;
for (const auto& action : raw_actions.as_array()) {
std::string awhat = ident + " action " + d(apos + 1);
++apos;
if (!action.is_object()) return fail(awhat + " must be an object");
auto [aid, e9] = validate_flow_id(jget(action, "id"), awhat);
if (!e9.empty()) return fail(e9);
if (in_list(action_ids, aid)) return fail(ident + " has a duplicate action id " + py::repr(aid));
action_ids.push_back(aid);
auto [label, e10] = validate_flow_text(jget_or(action, "label", Json::string("")), aid + " label", PLEXER_FLOW_LABEL_MAX);
if (!e10.empty()) return fail(e10);
if (py::strip(label).empty()) return fail(aid + " needs a non-empty label");
Json entry = Json::object();
entry.set("id", Json::string(aid));
entry.set("label", Json::string(label));
actions.push(entry);
}
if (py::strip(body).empty() && fields.as_array().empty() && actions.as_array().empty())
return fail(ident + " has nothing to show (no body, fields or actions)");
Json screen = Json::object();
screen.set("id", Json::string(ident));
screen.set("title", Json::string(title_s));
screen.set("body", Json::string(body));
screen.set("fields", fields);
screen.set("actions", actions);
screen.set("next", jget(item, "next"));
screen.set("show_if", jget(item, "show_if"));
screens.push(screen);
}
std::vector<std::string> known_fields;
for (const auto& screen : screens.as_array())
for (const auto& field : jget(screen, "fields").as_array()) {
const std::string& t = jget(field, "type").as_string();
if (t == "choice" || t == "text") known_fields.push_back(jget(field, "id").as_string());
}
for (auto& screen : screens.as_array()) {
for (auto& field : screen.get("fields")->as_array()) {
auto [clauses, err] = validate_flow_condition(jget(field, "show_if"), jget(field, "id").as_string(), known_fields);
if (!err.empty()) return fail(err);
field.set("show_if", clauses);
}
const std::string sid = jget(screen, "id").as_string();
{
auto [clauses, err] = validate_flow_condition(jget(screen, "show_if"), sid, known_fields);
if (!err.empty()) return fail(err);
screen.set("show_if", clauses);
}
Json target = jget(screen, "next");
auto known_target = [&](const Json& dest) {
return dest.is_string() && (dest.as_string() == "$done" || dest.as_string() == "$cancel" || in_list(screen_ids, dest.as_string()));
};
if (target.is_null()) {
screen.set("next", Json());
} else if (target.is_string()) {
if (!known_target(target)) return fail(sid + " next refers to unknown screen " + repr_json(target));
screen.set("next", target);
} else if (target.is_object()) {
if (jget(screen, "actions").as_array().empty()) return fail(sid + " next is a map but the screen has no actions");
for (const auto& entry : target.as_object()) {
bool found = false;
for (const auto& action : jget(screen, "actions").as_array())
if (jget(action, "id").as_string() == entry.first) found = true;
if (!found) return fail(sid + " next maps unknown action " + py::repr(entry.first));
if (!known_target(entry.second))
return fail(sid + " next for " + py::repr(entry.first) + " refers to unknown screen " + repr_json(entry.second));
}
screen.set("next", target);
} else {
return fail(sid + " next must be a screen id or an {action: screen} map");
}
}
Json start = jget_or(spec, "start", jget(screens.as_array()[0], "id"));
if (!(start.is_string() && in_list(screen_ids, start.as_string())))
return fail("start refers to unknown screen " + repr_json(start));
Json app = Json::object();
app.set("title", Json::string(title));
app.set("timeout", Json::integer(timeout));
app.set("start", start);
app.set("screens", screens);
return {app, ""};
}
// ---- PlexerFlow -----------------------------------------------------------------------------------
PlexerFlow::PlexerFlow(const Json& app_in) : app(app_in) {
for (const auto& screen : app.get("screens")->as_array()) {
by_id[jget(screen, "id").as_string()] = screen;
order.push_back(jget(screen, "id").as_string());
}
}
bool PlexerFlow::condition_holds(const Json& clauses, const Json& answers) {
if (!clauses.is_object()) return true;
for (const auto& entry : clauses.as_object()) {
const Json* have = answers.get(entry.first);
if (!have) return false;
bool hit = false;
for (const auto& accepted : entry.second.as_array())
if (have->equals(accepted)) hit = true;
if (!hit) return false;
}
return true;
}
std::string PlexerFlow::follow_up(const Json& screen) {
const Json& target = jget(screen, "next");
if (target.is_string()) return target.as_string();
size_t pos = 0;
const std::string& id = jget(screen, "id").as_string();
for (size_t i = 0; i < order.size(); ++i)
if (order[i] == id) pos = i;
if (pos + 1 < order.size()) return order[pos + 1];
return "$done";
}
const Json* PlexerFlow::first_askable(const Json& screen) {
for (const auto& field : jget(screen, "fields").as_array()) {
const std::string& t = jget(field, "type").as_string();
if ((t == "choice" || t == "text") && condition_holds(jget(field, "show_if"), answers)) return &field;
}
return nullptr;
}
std::string PlexerFlow::screen_text(const Json& screen, const Json* field) {
auto or_title = [&](const Json& first) {
const std::string& s = first.as_string();
return s.empty() ? jget(app, "title").as_string() : s;
};
if (field != nullptr && field != first_askable(screen)) {
const std::string& label = jget(*field, "label").as_string();
if (!label.empty()) return label;
return or_title(jget(screen, "title"));
}
std::vector<std::string> parts = {or_title(jget(screen, "title")), jget(screen, "body").as_string()};
for (const auto& item : jget(screen, "fields").as_array())
if (jget(item, "type").as_string() == "info" && condition_holds(jget(item, "show_if"), answers))
parts.push_back(jget(item, "text").as_string());
if (field != nullptr && !jget(*field, "label").as_string().empty()) parts.push_back(jget(*field, "label").as_string());
std::string out;
bool first = true;
for (const auto& part : parts) {
if (part.empty() || py::strip(part).empty()) continue;
if (!first) out += "\n\n";
first = false;
out += part;
}
return out;
}
double PlexerFlow::remaining() const { return deadline - py::monotonic(); }
namespace {
Json outcome_for(const std::string& status, const std::string& at, const Json& answers, const std::string& field = "") {
Json out = Json::object();
out.set("status", Json::string(status));
out.set("at", Json::string(at));
if (!field.empty()) out.set("field", Json::string(field));
out.set("answers", answers);
return out;
}
} // namespace
Json PlexerFlow::run(PlexerController& controller) {
deadline = py::monotonic() + static_cast<double>(jget(app, "timeout").as_int());
std::string current = jget(app, "start").as_string();
for (;;) {
++visits;
if (visits > PLEXER_FLOW_VISITS_MAX) {
Json out = Json::object();
out.set("status", Json::string("error"));
out.set("error", Json::string("flow passed " + d(PLEXER_FLOW_VISITS_MAX) + " screens (loop?), aborting"));
out.set("at", Json::string(current));
out.set("answers", answers);
return out;
}
const Json& screen = by_id.at(current);
std::string target;
if (condition_holds(jget(screen, "show_if"), answers)) {
ScreenResult result = run_screen(controller, screen);
if (result.kind == ScreenResult::Kind::Outcome) return result.outcome;
target = result.kind == ScreenResult::Kind::Jump ? result.target : follow_up(screen);
} else {
target = follow_up(screen);
}
if (target == "$done") {
Json out = Json::object();
out.set("status", Json::string("done"));
out.set("answers", answers);
out.set("action", action);
return out;
}
if (target == "$cancel") return outcome_for("cancelled", current, answers);
current = target;
}
}
PlexerFlow::ScreenResult PlexerFlow::run_screen(PlexerController& controller, const Json& screen) {
auto outcome = [](Json value) {
ScreenResult r;
r.kind = ScreenResult::Kind::Outcome;
r.outcome = std::move(value);
return r;
};
const std::string sid = jget(screen, "id").as_string();
const std::string screen_title = [&] {
const std::string& t = jget(screen, "title").as_string();
return t.empty() ? jget(app, "title").as_string() : t;
}();
bool asked = false;
for (const auto& field : jget(screen, "fields").as_array()) {
const std::string& type = jget(field, "type").as_string();
if (type == "info" || !condition_holds(jget(field, "show_if"), answers)) continue;
if (remaining() <= 0) return outcome(outcome_for("timeout", sid, answers));
const std::string fid = jget(field, "id").as_string();
if (type == "choice") {
Json ask = Json::object();
ask.set("title", Json::string(screen_title));
ask.set("text", Json::string(screen_text(screen, &field)));
ask.set("kind", Json::string("choice"));
Json labels = Json::array();
for (const auto& opt : jget(field, "options").as_array()) labels.push(jget(opt, "label"));
ask.set("options", labels);
ask.set("default", jget(field, "default"));
Json reply = controller.ask(ask, remaining());
if (jget(reply, "status").as_string() != "ok")
return outcome(outcome_for(jget(reply, "status").as_string(), sid, answers, fid));
const Json& opts = jget(field, "options");
long long idx = jget(reply, "value").is_number() ? jget(reply, "value").as_int() : -1;
if (idx < 0 || idx >= static_cast<long long>(opts.as_array().size())) throw PlexerError("list index out of range");
answers.set(fid, jget(opts.as_array()[static_cast<size_t>(idx)], "value"));
} else {
Json ask = Json::object();
ask.set("title", Json::string(screen_title));
ask.set("text", Json::string(screen_text(screen, &field)));
ask.set("kind", Json::string("text"));
ask.set("initial", jget(field, "initial"));
Json reply = controller.ask(ask, remaining());
if (jget(reply, "status").as_string() != "ok")
return outcome(outcome_for(jget(reply, "status").as_string(), sid, answers, fid));
answers.set(fid, jget(reply, "value"));
}
asked = true;
}
const Json& actions = jget(screen, "actions");
if (actions.as_array().empty()) {
if (!asked) {
if (remaining() <= 0) return outcome(outcome_for("timeout", sid, answers));
Json ask = Json::object();
ask.set("title", Json::string(screen_title));
ask.set("text", Json::string(screen_text(screen)));
ask.set("kind", Json::string("message"));
Json reply = controller.ask(ask, remaining());
if (jget(reply, "status").as_string() != "ok")
return outcome(outcome_for(jget(reply, "status").as_string(), sid, answers));
}
return ScreenResult{};
}
if (remaining() <= 0) return outcome(outcome_for("timeout", sid, answers));
std::string text;
if (first_askable(screen) == nullptr && py::strip(jget(screen, "body").as_string()).empty())
text = screen_title;
else if (first_askable(screen) == nullptr)
text = screen_text(screen);
else
text = screen_title;
Json ask = Json::object();
ask.set("title", Json::string(screen_title));
ask.set("text", Json::string(text));
ask.set("kind", Json::string("choice"));
Json labels = Json::array();
for (const auto& a : actions.as_array()) labels.push(jget(a, "label"));
ask.set("options", labels);
ask.set("default", Json::integer(0));
Json reply = controller.ask(ask, remaining());
if (jget(reply, "status").as_string() != "ok")
return outcome(outcome_for(jget(reply, "status").as_string(), sid, answers));
long long idx = jget(reply, "value").is_number() ? jget(reply, "value").as_int() : -1;
if (idx < 0 || idx >= static_cast<long long>(actions.as_array().size())) throw PlexerError("list index out of range");
action = jget(actions.as_array()[static_cast<size_t>(idx)], "id");
const Json& target = jget(screen, "next");
if (target.is_object()) {
const Json* dest = target.get(action.as_string());
if (dest == nullptr || dest->is_null()) return ScreenResult{};
if (dest->as_string() == "$done") {
Json out = Json::object();
out.set("status", Json::string("done"));
out.set("answers", answers);
out.set("action", action);
return outcome(out);
}
if (dest->as_string() == "$cancel") return outcome(outcome_for("cancelled", sid, answers));
ScreenResult r;
r.kind = ScreenResult::Kind::Jump;
r.target = dest->as_string();
return r;
}
return ScreenResult{};
}
} // namespace detz::mplex_flow