diff --git a/taicpp/Makefile b/taicpp/Makefile index dcb130d..00e3009 100644 --- a/taicpp/Makefile +++ b/taicpp/Makefile @@ -27,17 +27,17 @@ $(BUILD)/main.o: main.cpp $(VERSION_HPP) $(CXX) $(CXXFLAGS) -I$(GEN_INC) $(foreach d,$(shell find src -type d),-I$d) -c main.cpp -o $@ $(BIN): $(MAIN_OBJ) $(ALL_OBJS) - $(CXX) $(CXXFLAGS) $(MAIN_OBJ) $(ALL_OBJS) -lsqlite3 -lcurl -o $@ + $(CXX) $(CXXFLAGS) $(MAIN_OBJ) $(ALL_OBJS) -lsqlite3 -lcurl -lz -pthread -lutil -o $@ static: $(MAIN_OBJ) $(ALL_OBJS) $(CXX) $(CXXFLAGS) -pthread -static $(MAIN_OBJ) $(ALL_OBJS) third_party/curl/libcurl.a third_party/sqlite/libsqlite3.a -lssl -lcrypto third_party/jitterentropy/libjitterentropy.a -lzstd -lz -lnghttp2 -lpsl -lbrotlidec -lidn2 -lrtmp -o build/detz-static $(BUILD)/store_test: tests/store_test.cpp src/store/store.cpp @mkdir -p $(BUILD) - $(CXX) $(CXXFLAGS) -Isrc/store tests/store_test.cpp src/store/store.cpp -lsqlite3 -lcurl -o $@ + $(CXX) $(CXXFLAGS) -Isrc/store tests/store_test.cpp src/store/store.cpp -lsqlite3 -lcurl -lz -pthread -lutil -o $@ $(BUILD)/detz_test: tests/test_main.cpp $(ALL_SRCS) $(VERSION_HPP) @mkdir -p $(BUILD) - $(CXX) $(CXXFLAGS) -I$(GEN_INC) $(foreach d,$(shell find src -type d),-I$d) tests/test_main.cpp $(ALL_SRCS) -lsqlite3 -lcurl -o $@ + $(CXX) $(CXXFLAGS) -I$(GEN_INC) $(foreach d,$(shell find src -type d),-I$d) tests/test_main.cpp $(ALL_SRCS) -lsqlite3 -lcurl -lz -pthread -lutil -o $@ lint: $(CXX) $(CXXSTD) $(WARN) -Werror -fsyntax-only main.cpp -I$(GEN_INC) $(foreach d,$(shell find src -type d),-I$d) || true diff --git a/taicpp/src/mplex/mplex.mk b/taicpp/src/mplex/mplex.mk index 5f1d93c..0114b95 100644 --- a/taicpp/src/mplex/mplex.mk +++ b/taicpp/src/mplex/mplex.mk @@ -6,10 +6,10 @@ SRCS += src/mplex/mplex_json.cpp src/mplex/mplex_keys.cpp src/mplex/mplex_layout SRCS += src/mplex/mplex_options.cpp src/mplex/mplex_pane.cpp src/mplex/mplex_panenumbers.cpp src/mplex/mplex_prompt.cpp SRCS += src/mplex/mplex_py.cpp src/mplex/mplex_renderer.cpp src/mplex/mplex_server.cpp src/mplex/mplex_style.cpp SRCS += src/mplex/mplex_toml.cpp src/mplex/mplex_vtscreen.cpp -SRCS += src/plexer/plexer_flow.cpp +SRCS += src/mplex/mplex_plexer_flow.cpp # Standalone client/server binary and its tests; every output lands in $(BUILD). -MPLEX_LIB_SRCS = $(filter src/mplex/%.cpp src/plexer/plexer_flow.cpp,$(SRCS)) +MPLEX_LIB_SRCS = $(filter src/mplex/%.cpp src/mplex/mplex_plexer_flow.cpp,$(SRCS)) MPLEX_INC = -Isrc/mplex -Isrc/plexer $(BUILD)/mplex_cli: tests/mplex_cli_main.cpp $(MPLEX_LIB_SRCS) @mkdir -p $(BUILD) diff --git a/taicpp/src/mplex/mplex_plexer_flow.cpp b/taicpp/src/mplex/mplex_plexer_flow.cpp new file mode 100644 index 0000000..03fd840 --- /dev/null +++ b/taicpp/src/mplex/mplex_plexer_flow.cpp @@ -0,0 +1,1049 @@ +// Port of tai.py:9302-9985: PlexerController family, plexer_show spec validation, PlexerFlow. +#include "mplex_plexer_flow.h" + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#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 PLEXER_FLOW_TYPES = {"choice", "text", "info"}; +const std::vector 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 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(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& 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 make_temp(const std::string& suffix) { + std::string pattern = temp_dir() + "/tai-flow-XXXXXX" + suffix; + std::vector buf(pattern.begin(), pattern.end()); + buf.push_back('\0'); + int fd = ::mkstemps(buf.data(), static_cast(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(delay * 1e6)); + } + if (enter) send_keys("", true); +} + +// ---- NativePlexerController ----------------------------------------------------------------- + +NativePlexerController::NativePlexerController(const std::string& socket_path_in, const std::optional& 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(&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(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(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(PLEXER_FLOW_POLL * 1e6)); + } +} + +// ---- TmuxPlexerController --------------------------------------------------------------------- + +TmuxPlexerController::TmuxPlexerController(const std::string& pane_in) : pane(pane_in) { kind = "tmux"; } + +std::string TmuxPlexerController::tmux(const std::vector& args) { + std::vector 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 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& argv, const std::string& path, + double timeout) { + std::vector 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 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 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(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(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 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(socket_path, pane ? std::optional(pane) : std::nullopt); + } + const char* pane = std::getenv("TMUX_PANE"); + if (pane && *pane) return std::make_unique(pane); + return nullptr; +} + +// ---- validation -------------------------------------------------------------------------------- + +std::pair 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(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 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(limit)) + " chars"}; + return {raw.as_string(), ""}; +} + +std::pair 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(PLEXER_FLOW_OPTIONS_MAX)) + return {Json::array(), what + " has " + d(static_cast(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(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 validate_flow_condition(const Json& raw, const std::string& what, + const std::vector& 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; + +Fail fail(const std::string& reason) { return {Json(), reason}; } + +} // namespace + +std::pair 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(PLEXER_FLOW_SCREENS_MAX)) + return fail(d(static_cast(raw_screens.as_array().size())) + " screens, at most " + d(PLEXER_FLOW_SCREENS_MAX)); + Json screens = Json::array(); + std::vector 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(PLEXER_FLOW_FIELDS_MAX)) + return fail(ident + " has " + d(static_cast(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(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(PLEXER_FLOW_ACTIONS_MAX)) + return fail(ident + " has " + d(static_cast(raw_actions.as_array().size())) + " actions, at most " + + d(PLEXER_FLOW_ACTIONS_MAX)); + Json actions = Json::array(); + std::vector 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 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 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(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(opts.as_array().size())) throw PlexerError("list index out of range"); + answers.set(fid, jget(opts.as_array()[static_cast(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(actions.as_array().size())) throw PlexerError("list index out of range"); + action = jget(actions.as_array()[static_cast(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 diff --git a/taicpp/src/mplex/mplex_plexer_flow.h b/taicpp/src/mplex/mplex_plexer_flow.h new file mode 100644 index 0000000..fa87c76 --- /dev/null +++ b/taicpp/src/mplex/mplex_plexer_flow.h @@ -0,0 +1,156 @@ +// uiflow (uiflow.md): the controller side of the plexer protocol (PlexerController, its native +// and tmux backends, the factory), the plexer_show spec validation and the PlexerFlow driver. +// Port of tai.py:9302-9985. Namespace detz::plexer, names are the Python ones. +#pragma once +#include +#include +#include +#include +#include +#include +#include + +#include "mplex_json.h" + +namespace detz::mplex_flow { + +using detz::mplex::Json; + +constexpr const char* PLEXER_DIR_NAME = "plexer"; +constexpr const char* PLEXER_PANE_ENV = "TAI_PLEXER_PANE"; +constexpr const char* PLEXER_SOCKET_ENV = "TAI_PLEXER_SOCKET"; +constexpr const char* PLEXER_ACTIVE_ENV = "TAI_PLEXER_ACTIVE"; +constexpr long PLEXER_RECV_MAX = 1L << 20; +constexpr int PLEXER_FLOW_VERSION = 1; +constexpr int PLEXER_FLOW_SCREENS_MAX = 32; +constexpr int PLEXER_FLOW_FIELDS_MAX = 12; +constexpr int PLEXER_FLOW_OPTIONS_MAX = 12; +constexpr int PLEXER_FLOW_ACTIONS_MAX = 12; +constexpr int PLEXER_FLOW_VISITS_MAX = 64; +constexpr int PLEXER_FLOW_ID_MAX = 64; +constexpr int PLEXER_FLOW_TITLE_MAX = 200; +constexpr int PLEXER_FLOW_TEXT_MAX = 2000; +constexpr int PLEXER_FLOW_LABEL_MAX = 200; +constexpr int PLEXER_FLOW_ANSWER_MAX = 2000; +constexpr int PLEXER_FLOW_TIMEOUT_DEFAULT = 300; +constexpr int PLEXER_FLOW_TIMEOUT_MIN = 5; +constexpr int PLEXER_FLOW_TIMEOUT_MAX = 600; +constexpr double PLEXER_FLOW_POLL = 0.25; +extern const std::vector PLEXER_FLOW_TYPES; // choice text info +extern const std::vector PLEXER_FLOW_KINDS; // message choice text +constexpr int MPLEX_FLOW_MAX = 16; +constexpr int MPLEX_FLOW_KEEP = 300; + +// plexer_dir(home): /plexer, created 0700. +std::string plexer_dir(const std::string& home); + +// Raised by a PlexerController when the backend can't be reached or refuses a command. +class PlexerError : public std::runtime_error { + public: + explicit PlexerError(const std::string& text) : std::runtime_error(text) {} +}; + +// ---- controller side --------------------------------------------------------------------- + +class PlexerController { + public: + virtual ~PlexerController() = default; + std::string kind = "abstract"; + virtual Json send_keys(const std::string& text, bool enter = false) = 0; + virtual Json read_pane(int lines = 200) = 0; + void type_text(const std::string& text, double delay = 0.03, bool enter = true); + virtual Json status() = 0; + // One blocking question on the user's terminal. screen: {"title","text","kind","options","initial","default"}. + // Returns {"status":"ok","value":...} / {"status":"cancelled"} / {"status":"timeout"}; throws PlexerError. + virtual Json ask(const Json& screen, double timeout) = 0; +}; + +class NativePlexerController : public PlexerController { + public: + NativePlexerController(const std::string& socket_path_in, const std::optional& pane_in = std::nullopt); + std::string socket_path; + std::optional pane; + Json call(const Json& payload); // Python _call + Json send_keys(const std::string& text, bool enter = false) override; + Json read_pane(int lines = 200) override; + Json status() override; + Json ask(const Json& screen, double timeout) override; +}; + +class TmuxPlexerController : public PlexerController { + public: + explicit TmuxPlexerController(const std::string& pane_in); + std::string pane; + std::string tmux(const std::vector& args); // Python _tmux + Json send_keys(const std::string& text, bool enter = false) override; + Json read_pane(int lines = 200) override; + Json status() override; + Json ask(const Json& screen, double timeout) override; + std::string ask_result_path(); + std::optional await_answer(const std::string& path, double budget = 1.0); + void ask_dismiss(); + // Returns the answer file content, nullopt when dismissed, or "timeout" via timed_out. + struct RunOutcome { + bool timed_out = false; + std::optional content; + }; + RunOutcome ask_run(const std::vector& argv, const std::string& path, double timeout); + Json ask_choice(const Json& screen, double timeout); + RunOutcome ask_popup(const std::string& title, const std::string& body, const std::string& reader, double timeout); + Json ask_text(const Json& screen, double timeout); + Json ask_message(const Json& screen, double timeout); +}; + +class PlexerControllerFactory { + public: + // Which env var is set decides the backend; null when neither is. + static std::unique_ptr detect(); +}; + +// ---- spec validation ------------------------------------------------------------------------ + +// Each returns the normalized value and an error text ("" when fine), like the Python tuples. +std::pair validate_flow_id(const Json& raw, const std::string& what); +std::pair validate_flow_text(const Json& raw, const std::string& what, size_t limit); +std::pair validate_flow_options(const Json& raw, const std::string& what); +std::pair validate_flow_condition(const Json& raw, const std::string& what, + const std::vector& known); +// Returns (normalized app, "") or (Json null, reason). +std::pair validate_flow_spec(const Json& spec); + +// ---- the flow driver ---------------------------------------------------------------------------- + +class PlexerFlow { + public: + explicit PlexerFlow(const Json& app); + Json app; + std::map by_id; + std::vector order; + Json answers = Json::object(); + Json action; // None until an action is taken + int visits = 0; + double deadline = 0.0; + + static bool condition_holds(const Json& clauses, const Json& answers); + std::string follow_up(const Json& screen); + std::string screen_text(const Json& screen, const Json* field = nullptr); + const Json* first_askable(const Json& screen); + double remaining() const; + Json run(PlexerController& controller); + // Returns: null optional to continue with follow_up, a screen id, or a final outcome. + struct ScreenResult { + enum class Kind { Continue, Jump, Outcome } kind = Kind::Continue; + std::string target; + Json outcome; + }; + ScreenResult run_screen(PlexerController& controller, const Json& screen); +}; + +// PlexerBackend (the extension point the shell hook execs into). +class PlexerBackend { + public: + virtual ~PlexerBackend() = default; + virtual int run() = 0; +}; + +} // namespace detz::mplex_flow diff --git a/taicpp/src/plexer/plexer_flow.cpp b/taicpp/src/plexer/plexer_flow.cpp index 8aed8f1..339ed92 100644 --- a/taicpp/src/plexer/plexer_flow.cpp +++ b/taicpp/src/plexer/plexer_flow.cpp @@ -1,1049 +1,197 @@ -// Port of tai.py:9302-9985: PlexerController family, plexer_show spec validation, PlexerFlow. #include "plexer_flow.h" - -#include -#include -#include -#include -#include -#include -#include - #include -#include -#include -#include -#include -#include - -#include "mplex_common.h" -#include "mplex_py.h" - +#include "plexer_consts.h" +#include "plexer_error.h" +#include "store/utf8.h" namespace detz::plexer { -namespace py = detz::mplex::py; -using detz::mplex::to_ustr; -using detz::mplex::UStr; - -const std::vector PLEXER_FLOW_TYPES = {"choice", "text", "info"}; -const std::vector PLEXER_FLOW_KINDS = {"message", "choice", "text"}; +using store::Json; +using store::Utf8; 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"; +std::string first_nonempty(const std::string& a, const std::string& b) { return a.empty() ? b : a; } +const Json& get(const Json& obj, const char* key) { + static const Json null_json; + const Json* v = obj.find(key); + return v ? *v : null_json; } - -// 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 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(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& 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 make_temp(const std::string& suffix) { - std::string pattern = temp_dir() + "/tai-flow-XXXXXX" + suffix; - std::vector buf(pattern.begin(), pattern.end()); - buf.push_back('\0'); - int fd = ::mkstemps(buf.data(), static_cast(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(delay * 1e6)); - } - if (enter) send_keys("", true); -} - -// ---- NativePlexerController ----------------------------------------------------------------- - -NativePlexerController::NativePlexerController(const std::string& socket_path_in, const std::optional& 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(&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(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(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(PLEXER_FLOW_POLL * 1e6)); - } -} - -// ---- TmuxPlexerController --------------------------------------------------------------------- - -TmuxPlexerController::TmuxPlexerController(const std::string& pane_in) : pane(pane_in) { kind = "tmux"; } - -std::string TmuxPlexerController::tmux(const std::vector& args) { - std::vector 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 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& argv, const std::string& path, - double timeout) { - std::vector 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 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 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(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(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 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(socket_path, pane ? std::optional(pane) : std::nullopt); - } - const char* pane = std::getenv("TMUX_PANE"); - if (pane && *pane) return std::make_unique(pane); - return nullptr; -} - -// ---- validation -------------------------------------------------------------------------------- - -std::pair 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(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 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(limit)) + " chars"}; - return {raw.as_string(), ""}; -} - -std::pair 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(PLEXER_FLOW_OPTIONS_MAX)) - return {Json::array(), what + " has " + d(static_cast(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(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 validate_flow_condition(const Json& raw, const std::string& what, - const std::vector& 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; - -Fail fail(const std::string& reason) { return {Json(), reason}; } - -} // namespace - -std::pair 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(PLEXER_FLOW_SCREENS_MAX)) - return fail(d(static_cast(raw_screens.as_array().size())) + " screens, at most " + d(PLEXER_FLOW_SCREENS_MAX)); - Json screens = Json::array(); - std::vector 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(PLEXER_FLOW_FIELDS_MAX)) - return fail(ident + " has " + d(static_cast(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(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(PLEXER_FLOW_ACTIONS_MAX)) - return fail(ident + " has " + d(static_cast(raw_actions.as_array().size())) + " actions, at most " + - d(PLEXER_FLOW_ACTIONS_MAX)); - Json actions = Json::array(); - std::vector 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 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()); +PlexerFlow::PlexerFlow(Json app) : app_(std::move(app)) { + for (auto& screen : app_.find("screens")->items()) { + by_id_[screen.get_string("id")] = &screen; + order_.push_back(screen.get_string("id")); } } 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; + for (auto& clause : clauses.members()) { + const Json* have = answers.find(clause.first); + if (!have) return false; // MISSING is never in accepted + bool found = false; + for (auto& accepted : clause.second.items()) if (accepted == *have) { found = true; break; } + if (!found) return false; } return true; } -std::string PlexerFlow::follow_up(const Json& screen) { - const Json& target = jget(screen, "next"); +std::string PlexerFlow::follow_up(const Json& screen) const { + const Json& target = get(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]; + auto pos = std::find(order_.begin(), order_.end(), screen.get_string("id")) - order_.begin(); + if (static_cast(pos) + 1 < order_.size()) return order_[static_cast(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; +int PlexerFlow::first_askable(const Json& screen) const { + int idx = 0; + for (auto& field : get(screen, "fields").items()) { + std::string t = field.get_string("type"); + if ((t == "choice" || t == "text") && condition_holds(get(field, "show_if"), answers_)) return idx; + idx++; } - return nullptr; + return -1; } -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::string PlexerFlow::screen_text(const Json& screen, int field_index) const { + const Json* field = field_index >= 0 ? &get(screen, "fields").items()[static_cast(field_index)] : nullptr; + std::string app_title = app_.get_string("title"); + if (field && field_index != first_askable(screen)) return first_nonempty(field->get_string("label"), first_nonempty(screen.get_string("title"), app_title)); + std::vector parts{first_nonempty(screen.get_string("title"), app_title), screen.get_string("body")}; + for (auto& item : get(screen, "fields").items()) { + if (item.get_string("type") == "info" && condition_holds(get(item, "show_if"), answers_)) parts.push_back(item.get_string("text")); } - std::vector 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()); + if (field && !field->get_string("label").empty()) parts.push_back(field->get_string("label")); 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; + for (auto& p : parts) { + if (p.empty() || Utf8::strip(p).empty()) continue; + if (!out.empty()) out += "\n\n"; + out += p; } 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; +double PlexerFlow::remaining() const { + return std::chrono::duration(deadline_ - std::chrono::steady_clock::now()).count(); } -} // namespace +PlexerFlow::Json PlexerFlow::result_with(const std::string& status, const std::string& at, const std::string& field) const { + Json o = Json::object(); + o.set("status", status); + o.set("at", at); + if (!field.empty()) o.set("field", field); + o.set("answers", answers_); + return o; +} -Json PlexerFlow::run(PlexerController& controller) { - deadline = py::monotonic() + static_cast(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; +PlexerFlow::Json PlexerFlow::run(PlexerController& controller) { + deadline_ = std::chrono::steady_clock::now() + std::chrono::duration_cast(std::chrono::duration(app_.find("timeout")->as_double())); + std::string current = app_.get_string("start"); + while (true) { + visits_++; + if (visits_ > Plexer::kFlowVisitsMax) { + Json o = Json::object(); + o.set("status", "error"); + o.set("error", "flow passed " + std::to_string(Plexer::kFlowVisitsMax) + " screens (loop?), aborting"); + o.set("at", current); + o.set("answers", answers_); + return o; } - const Json& screen = by_id.at(current); + 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); + if (condition_holds(get(screen, "show_if"), answers_)) { + Outcome outcome = run_screen(controller, screen); + if (outcome.kind == Outcome::Kind::Final) return outcome.result; + target = outcome.kind == Outcome::Kind::Jump ? outcome.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; + Json o = Json::object(); + o.set("status", "done"); o.set("answers", answers_); o.set("action", action_); + return o; } - if (target == "$cancel") return outcome_for("cancelled", current, answers); + if (target == "$cancel") return result_with("cancelled", current); 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; - }(); +PlexerFlow::Outcome PlexerFlow::run_screen(PlexerController& controller, const Json& screen) { + auto final_of = [&](Json j) { Outcome o; o.kind = Outcome::Kind::Final; o.result = std::move(j); return o; }; + std::string sid = screen.get_string("id"); + std::string title = first_nonempty(screen.get_string("title"), app_.get_string("title")); 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(); + const auto& fields = get(screen, "fields").items(); + for (size_t fi = 0; fi < fields.size(); fi++) { + const Json& field = fields[fi]; + std::string type = field.get_string("type"); + if (type == "info" || !condition_holds(get(field, "show_if"), answers_)) continue; + if (remaining() <= 0) return final_of(result_with("timeout", sid)); + Json ask = Json::object(); + ask.set("title", title); + ask.set("text", screen_text(screen, static_cast(fi))); + Json reply; 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(opts.as_array().size())) throw PlexerError("list index out of range"); - answers.set(fid, jget(opts.as_array()[static_cast(idx)], "value")); + for (auto& opt : get(field, "options").items()) labels.push(Json(opt.get_string("label"))); + ask.set("kind", "choice"); ask.set("options", labels); ask.set("default", get(field, "default")); + reply = controller.ask(ask, remaining()); + if (reply.get_string("status") != "ok") return final_of(result_with(reply.get_string("status"), sid, field.get_string("id"))); + const Json* v = reply.find("value"); + auto idx = v ? v->as_int() : -1; + const auto& opts = get(field, "options").items(); + if (idx < 0 || idx >= static_cast(opts.size())) throw PlexerError("choice index out of range"); + answers_.set(field.get_string("id"), get(opts[static_cast(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")); + ask.set("kind", "text"); ask.set("initial", get(field, "initial")); + reply = controller.ask(ask, remaining()); + if (reply.get_string("status") != "ok") return final_of(result_with(reply.get_string("status"), sid, field.get_string("id"))); + answers_.set(field.get_string("id"), get(reply, "value")); } asked = true; } - const Json& actions = jget(screen, "actions"); - if (actions.as_array().empty()) { + const auto& actions = get(screen, "actions").items(); + if (actions.empty()) { if (!asked) { - if (remaining() <= 0) return outcome(outcome_for("timeout", sid, answers)); + if (remaining() <= 0) return final_of(result_with("timeout", sid)); 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")); + ask.set("title", title); ask.set("text", screen_text(screen)); ask.set("kind", "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)); + if (reply.get_string("status") != "ok") return final_of(result_with(reply.get_string("status"), sid)); } - return ScreenResult{}; + return Outcome{}; } - if (remaining() <= 0) return outcome(outcome_for("timeout", sid, answers)); + if (remaining() <= 0) return final_of(result_with("timeout", sid)); 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")); + if (first_askable(screen) < 0 && Utf8::strip(screen.get_string("body")).empty()) text = title; + else if (first_askable(screen) < 0) text = screen_text(screen); + else text = title; 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)); + for (auto& a : actions) labels.push(Json(a.get_string("label"))); + Json ask = Json::object(); + ask.set("title", title); ask.set("text", text); ask.set("kind", "choice"); ask.set("options", labels); ask.set("default", 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(actions.as_array().size())) throw PlexerError("list index out of range"); - action = jget(actions.as_array()[static_cast(idx)], "id"); - const Json& target = jget(screen, "next"); + if (reply.get_string("status") != "ok") return final_of(result_with(reply.get_string("status"), sid)); + const Json* v = reply.find("value"); + auto idx = v ? v->as_int() : -1; + if (idx < 0 || idx >= static_cast(actions.size())) throw PlexerError("choice index out of range"); + action_ = Json(actions[static_cast(idx)].get_string("id")); + const Json& target = get(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); + const Json* dest = target.find(action_.as_string()); + if (!dest) return Outcome{}; + if (dest->is_string() && dest->as_string() == "$done") { + Json o = Json::object(); + o.set("status", "done"); o.set("answers", answers_); o.set("action", action_); + return final_of(o); } - 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; + if (dest->is_string() && dest->as_string() == "$cancel") return final_of(result_with("cancelled", sid)); + Outcome o; + o.kind = Outcome::Kind::Jump; + o.target = dest->as_string(); + return o; } - return ScreenResult{}; + return Outcome{}; } } // namespace detz::plexer diff --git a/taicpp/src/plexer/plexer_flow.h b/taicpp/src/plexer/plexer_flow.h index 84d6fb5..38857d7 100644 --- a/taicpp/src/plexer/plexer_flow.h +++ b/taicpp/src/plexer/plexer_flow.h @@ -1,156 +1,48 @@ -// uiflow (uiflow.md): the controller side of the plexer protocol (PlexerController, its native -// and tmux backends, the factory), the plexer_show spec validation and the PlexerFlow driver. -// Port of tai.py:9302-9985. Namespace detz::plexer, names are the Python ones. #pragma once -#include +#include #include -#include -#include #include -#include #include - -#include "mplex_json.h" - +#include "plexer_controller.h" +#include "store/json.h" namespace detz::plexer { -using detz::mplex::Json; - -constexpr const char* PLEXER_DIR_NAME = "plexer"; -constexpr const char* PLEXER_PANE_ENV = "TAI_PLEXER_PANE"; -constexpr const char* PLEXER_SOCKET_ENV = "TAI_PLEXER_SOCKET"; -constexpr const char* PLEXER_ACTIVE_ENV = "TAI_PLEXER_ACTIVE"; -constexpr long PLEXER_RECV_MAX = 1L << 20; -constexpr int PLEXER_FLOW_VERSION = 1; -constexpr int PLEXER_FLOW_SCREENS_MAX = 32; -constexpr int PLEXER_FLOW_FIELDS_MAX = 12; -constexpr int PLEXER_FLOW_OPTIONS_MAX = 12; -constexpr int PLEXER_FLOW_ACTIONS_MAX = 12; -constexpr int PLEXER_FLOW_VISITS_MAX = 64; -constexpr int PLEXER_FLOW_ID_MAX = 64; -constexpr int PLEXER_FLOW_TITLE_MAX = 200; -constexpr int PLEXER_FLOW_TEXT_MAX = 2000; -constexpr int PLEXER_FLOW_LABEL_MAX = 200; -constexpr int PLEXER_FLOW_ANSWER_MAX = 2000; -constexpr int PLEXER_FLOW_TIMEOUT_DEFAULT = 300; -constexpr int PLEXER_FLOW_TIMEOUT_MIN = 5; -constexpr int PLEXER_FLOW_TIMEOUT_MAX = 600; -constexpr double PLEXER_FLOW_POLL = 0.25; -extern const std::vector PLEXER_FLOW_TYPES; // choice text info -extern const std::vector PLEXER_FLOW_KINDS; // message choice text -constexpr int MPLEX_FLOW_MAX = 16; -constexpr int MPLEX_FLOW_KEEP = 300; - -// plexer_dir(home): /plexer, created 0700. -std::string plexer_dir(const std::string& home); - -// Raised by a PlexerController when the backend can't be reached or refuses a command. -class PlexerError : public std::runtime_error { - public: - explicit PlexerError(const std::string& text) : std::runtime_error(text) {} -}; - -// ---- controller side --------------------------------------------------------------------- - -class PlexerController { - public: - virtual ~PlexerController() = default; - std::string kind = "abstract"; - virtual Json send_keys(const std::string& text, bool enter = false) = 0; - virtual Json read_pane(int lines = 200) = 0; - void type_text(const std::string& text, double delay = 0.03, bool enter = true); - virtual Json status() = 0; - // One blocking question on the user's terminal. screen: {"title","text","kind","options","initial","default"}. - // Returns {"status":"ok","value":...} / {"status":"cancelled"} / {"status":"timeout"}; throws PlexerError. - virtual Json ask(const Json& screen, double timeout) = 0; -}; - -class NativePlexerController : public PlexerController { - public: - NativePlexerController(const std::string& socket_path_in, const std::optional& pane_in = std::nullopt); - std::string socket_path; - std::optional pane; - Json call(const Json& payload); // Python _call - Json send_keys(const std::string& text, bool enter = false) override; - Json read_pane(int lines = 200) override; - Json status() override; - Json ask(const Json& screen, double timeout) override; -}; - -class TmuxPlexerController : public PlexerController { - public: - explicit TmuxPlexerController(const std::string& pane_in); - std::string pane; - std::string tmux(const std::vector& args); // Python _tmux - Json send_keys(const std::string& text, bool enter = false) override; - Json read_pane(int lines = 200) override; - Json status() override; - Json ask(const Json& screen, double timeout) override; - std::string ask_result_path(); - std::optional await_answer(const std::string& path, double budget = 1.0); - void ask_dismiss(); - // Returns the answer file content, nullopt when dismissed, or "timeout" via timed_out. - struct RunOutcome { - bool timed_out = false; - std::optional content; - }; - RunOutcome ask_run(const std::vector& argv, const std::string& path, double timeout); - Json ask_choice(const Json& screen, double timeout); - RunOutcome ask_popup(const std::string& title, const std::string& body, const std::string& reader, double timeout); - Json ask_text(const Json& screen, double timeout); - Json ask_message(const Json& screen, double timeout); -}; - -class PlexerControllerFactory { - public: - // Which env var is set decides the backend; null when neither is. - static std::unique_ptr detect(); -}; - -// ---- spec validation ------------------------------------------------------------------------ - -// Each returns the normalized value and an error text ("" when fine), like the Python tuples. -std::pair validate_flow_id(const Json& raw, const std::string& what); -std::pair validate_flow_text(const Json& raw, const std::string& what, size_t limit); -std::pair validate_flow_options(const Json& raw, const std::string& what); -std::pair validate_flow_condition(const Json& raw, const std::string& what, - const std::vector& known); -// Returns (normalized app, "") or (Json null, reason). -std::pair validate_flow_spec(const Json& spec); - -// ---- the flow driver ---------------------------------------------------------------------------- - +// The driver of one UI flow: the main agent hands over a whole validated app +// in a single plexer_show call, this decomposes it into PlexerController::ask +// calls, evaluates conditions and branches locally, and reports only the +// final outcome (uiflow.md). class PlexerFlow { public: - explicit PlexerFlow(const Json& app); - Json app; - std::map by_id; - std::vector order; - Json answers = Json::object(); - Json action; // None until an action is taken - int visits = 0; - double deadline = 0.0; - - static bool condition_holds(const Json& clauses, const Json& answers); - std::string follow_up(const Json& screen); - std::string screen_text(const Json& screen, const Json* field = nullptr); - const Json* first_askable(const Json& screen); - double remaining() const; + using Json = store::Json; + explicit PlexerFlow(Json app); + PlexerFlow(const PlexerFlow&) = delete; + PlexerFlow& operator=(const PlexerFlow&) = delete; + // {"status": done|cancelled|timeout|error, "answers": {...}, "action": ..., "at": ..., "field": ...} Json run(PlexerController& controller); - // Returns: null optional to continue with follow_up, a screen id, or a final outcome. - struct ScreenResult { - enum class Kind { Continue, Jump, Outcome } kind = Kind::Continue; - std::string target; - Json outcome; - }; - ScreenResult run_screen(PlexerController& controller, const Json& screen); -}; -// PlexerBackend (the extension point the shell hook execs into). -class PlexerBackend { - public: - virtual ~PlexerBackend() = default; - virtual int run() = 0; + // answers.get(key, MISSING) in accepted for every clause + static bool condition_holds(const Json& clauses, const Json& answers); + + private: + struct Outcome { + enum class Kind { Continue, Jump, Final } kind{Kind::Continue}; + std::string target; + Json result; + }; + std::string follow_up(const Json& screen) const; + std::string screen_text(const Json& screen, int field_index = -1) const; + int first_askable(const Json& screen) const; // index into fields, -1 for none + double remaining() const; + Outcome run_screen(PlexerController& controller, const Json& screen); + Json result_with(const std::string& status, const std::string& at, const std::string& field = "") const; + + Json app_; + std::map by_id_; + std::vector order_; + Json answers_ = Json::object(); + Json action_; + int visits_{0}; + std::chrono::steady_clock::time_point deadline_; }; } // namespace detz::plexer diff --git a/taicpp/src/shell/audio.cpp b/taicpp/src/shell/audio.cpp index 4353538..4b2d771 100644 --- a/taicpp/src/shell/audio.cpp +++ b/taicpp/src/shell/audio.cpp @@ -1,7 +1,7 @@ #include "audio.h" #include "proc.h" -#include "text.h" -#include "utf8.h" +#include "../util/text.h" +#include "../util/utf8.h" #include #include #include diff --git a/taicpp/src/shell/edge.cpp b/taicpp/src/shell/edge.cpp index 6a85449..dd2bc6b 100644 --- a/taicpp/src/shell/edge.cpp +++ b/taicpp/src/shell/edge.cpp @@ -1,6 +1,6 @@ #include "edge.h" #include "digest.h" -#include "utf8.h" +#include "../util/utf8.h" #include #include #include diff --git a/taicpp/src/shell/glow.cpp b/taicpp/src/shell/glow.cpp index d6c7075..d15ea43 100644 --- a/taicpp/src/shell/glow.cpp +++ b/taicpp/src/shell/glow.cpp @@ -1,9 +1,9 @@ #include "glow.h" #include "ansi.h" #include "pypath.h" -#include "text.h" +#include "../util/text.h" #include "toolargs.h" -#include "utf8.h" +#include "../util/utf8.h" #include #include namespace detz::shell { diff --git a/taicpp/src/shell/livestream.cpp b/taicpp/src/shell/livestream.cpp index ded473d..6e026f6 100644 --- a/taicpp/src/shell/livestream.cpp +++ b/taicpp/src/shell/livestream.cpp @@ -1,7 +1,7 @@ #include "livestream.h" #include "ansi.h" -#include "text.h" -#include "utf8.h" +#include "../util/text.h" +#include "../util/utf8.h" #include #include namespace detz::shell { diff --git a/taicpp/src/shell/proc.cpp b/taicpp/src/shell/proc.cpp index 39fe918..c6c4281 100644 --- a/taicpp/src/shell/proc.cpp +++ b/taicpp/src/shell/proc.cpp @@ -1,5 +1,5 @@ #include "proc.h" -#include "utf8.h" +#include "../util/utf8.h" #include "jsonvalue.h" #include #include diff --git a/taicpp/src/shell/pty.cpp b/taicpp/src/shell/ptyproc.cpp similarity index 99% rename from taicpp/src/shell/pty.cpp rename to taicpp/src/shell/ptyproc.cpp index 47245a9..a5d9aae 100644 --- a/taicpp/src/shell/pty.cpp +++ b/taicpp/src/shell/ptyproc.cpp @@ -1,4 +1,4 @@ -#include "pty.h" +#include "ptyproc.h" #include #include #include diff --git a/taicpp/src/shell/pty.h b/taicpp/src/shell/ptyproc.h similarity index 100% rename from taicpp/src/shell/pty.h rename to taicpp/src/shell/ptyproc.h diff --git a/taicpp/src/shell/runlive.cpp b/taicpp/src/shell/runlive.cpp index 5e91f0b..e0bf65d 100644 --- a/taicpp/src/shell/runlive.cpp +++ b/taicpp/src/shell/runlive.cpp @@ -1,6 +1,6 @@ #include "runlive.h" -#include "pty.h" -#include "utf8.h" +#include "ptyproc.h" +#include "../util/utf8.h" #include #include #include diff --git a/taicpp/src/shell/sandbox.cpp b/taicpp/src/shell/sandbox.cpp index 1f2ae65..d6d4bc7 100644 --- a/taicpp/src/shell/sandbox.cpp +++ b/taicpp/src/shell/sandbox.cpp @@ -1,8 +1,8 @@ #include "sandbox.h" #include "ansi.h" #include "boxdata.h" -#include "text.h" -#include "utf8.h" +#include "../util/text.h" +#include "../util/utf8.h" #include #include #include diff --git a/taicpp/src/shell/session.cpp b/taicpp/src/shell/session.cpp index 78c7b47..12fd9d3 100644 --- a/taicpp/src/shell/session.cpp +++ b/taicpp/src/shell/session.cpp @@ -1,8 +1,8 @@ #include "session.h" #include "jsonvalue.h" #include "pypath.h" -#include "text.h" -#include "utf8.h" +#include "../util/text.h" +#include "../util/utf8.h" #include #include #include diff --git a/taicpp/src/shell/shell.mk b/taicpp/src/shell/shell.mk index c8c124f..d1a908e 100644 --- a/taicpp/src/shell/shell.mk +++ b/taicpp/src/shell/shell.mk @@ -1 +1 @@ -SHELL_SRCS = src/shell/audio.cpp src/shell/boxdata.cpp src/shell/edge.cpp src/shell/glow.cpp src/shell/lanes.cpp src/shell/livestream.cpp src/shell/proc.cpp src/shell/pty.cpp src/shell/runlive.cpp src/shell/sandbox.cpp src/shell/session.cpp src/shell/shellio.cpp src/shell/spinner.cpp src/shell/targets.cpp +SHELL_SRCS = src/shell/audio.cpp src/shell/boxdata.cpp src/shell/edge.cpp src/shell/glow.cpp src/shell/lanes.cpp src/shell/livestream.cpp src/shell/proc.cpp src/shell/ptyproc.cpp src/shell/runlive.cpp src/shell/sandbox.cpp src/shell/session.cpp src/shell/shellio.cpp src/shell/spinner.cpp src/shell/targets.cpp diff --git a/taicpp/src/shell/shellio.cpp b/taicpp/src/shell/shellio.cpp index 7927e9f..4db50f1 100644 --- a/taicpp/src/shell/shellio.cpp +++ b/taicpp/src/shell/shellio.cpp @@ -1,6 +1,6 @@ #include "shellio.h" #include "ansi.h" -#include "utf8.h" +#include "../util/utf8.h" #include #include #include diff --git a/taicpp/src/shell/spinner.cpp b/taicpp/src/shell/spinner.cpp index 8e86a37..c1d65c6 100644 --- a/taicpp/src/shell/spinner.cpp +++ b/taicpp/src/shell/spinner.cpp @@ -2,7 +2,7 @@ #include "ansi.h" #include "glow.h" #include "proc.h" -#include "utf8.h" +#include "../util/utf8.h" #include #include #include diff --git a/taicpp/src/shell/targets.cpp b/taicpp/src/shell/targets.cpp index 8166271..e12e99b 100644 --- a/taicpp/src/shell/targets.cpp +++ b/taicpp/src/shell/targets.cpp @@ -2,7 +2,7 @@ #include "pyglob.h" #include "pypath.h" #include "shlex.h" -#include "utf8.h" +#include "../util/utf8.h" #include namespace detz::shell { namespace { diff --git a/taicpp/tests/mplex_flow_test.cpp b/taicpp/tests/mplex_flow_test.cpp index 07e7aec..12533b1 100644 --- a/taicpp/tests/mplex_flow_test.cpp +++ b/taicpp/tests/mplex_flow_test.cpp @@ -10,10 +10,10 @@ #include #include -#include "plexer_flow.h" +#include "mplex_plexer_flow.h" using detz::mplex::Json; -using namespace detz::plexer; +using namespace detz::mplex_flow; namespace { diff --git a/taicpp/tests/mplex_regress_test.cpp b/taicpp/tests/mplex_regress_test.cpp index b340915..e996713 100644 --- a/taicpp/tests/mplex_regress_test.cpp +++ b/taicpp/tests/mplex_regress_test.cpp @@ -1,6 +1,6 @@ // Regression tests for C++-vs-Python semantic traps found by the audit (G2): const-reference // aliasing of members, killed objects reachable from the argument, buffer self-reference. -// Links all of src/mplex and src/plexer/plexer_flow.cpp. Plain main + asserts. +// Links all of src/mplex and src/mplex/mplex_plexer_flow.cpp. Plain main + asserts. #include #include diff --git a/taicpp/tests/mplex_spec_test.cpp b/taicpp/tests/mplex_spec_test.cpp index ce29fe1..dbb5441 100644 --- a/taicpp/tests/mplex_spec_test.cpp +++ b/taicpp/tests/mplex_spec_test.cpp @@ -18,7 +18,7 @@ #include "mplex_copymode.h" #include "mplex_options.h" #include "mplex_server.h" -#include "plexer_flow.h" +#include "mplex_plexer_flow.h" using namespace detz::mplex; @@ -201,7 +201,7 @@ int main(int argc, char** argv) { "PLEXER_FLOW_TIMEOUT_DEFAULT", "PLEXER_FLOW_TIMEOUT_MAX", "PLEXER_FLOW_POLL", "MPLEX_FLOW_MAX", "MPLEX_FLOW_KEEP"}; for (const auto& name : caps) check(contains(flow, "`" + name + "`"), "flow cap documented: " + name); - namespace pf = detz::plexer; + namespace pf = detz::mplex_flow; check(pf::PLEXER_FLOW_VERSION == 1 && pf::PLEXER_FLOW_SCREENS_MAX == 32 && pf::PLEXER_FLOW_FIELDS_MAX == 12 && pf::PLEXER_FLOW_OPTIONS_MAX == 12 && pf::PLEXER_FLOW_ACTIONS_MAX == 12 && pf::PLEXER_FLOW_VISITS_MAX == 64 && pf::PLEXER_FLOW_ID_MAX == 64 && pf::PLEXER_FLOW_TITLE_MAX == 200 && pf::PLEXER_FLOW_TEXT_MAX == 2000 &&