clients/cpp: v0.2 multi-turn Session API
- Session move-only RAII; destructor best-effort close
- Client::create_session / list_sessions / get_session
- TurnResult.text() helper
- All post-parse json::get<T>() wrapped via with_protocol_guard (no nlohmann leak)
- tests/test_session.cpp: ~12 tests covering RAII/idempotency/move/list/state/404/protocol-error/regression
- ASan + UBSan clean
- README "Multi-turn / Sessions (v0.2)" section
v0.1 surface unchanged. C++17 preserved. cpp-httplib 0.20.1 preserved.
Spec: memory/spec-clawdforge-v0.2.md
Server core: 940861f
This commit is contained in:
parent
22e57e3dad
commit
1f6606d3b9
6 changed files with 1208 additions and 1 deletions
|
|
@ -2,6 +2,9 @@
|
|||
|
||||
#include <clawdforge/client.hpp>
|
||||
|
||||
#include <atomic>
|
||||
#include <cstdio>
|
||||
#include <exception>
|
||||
#include <filesystem>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
|
|
@ -262,4 +265,282 @@ void Client::revoke_token(std::string_view name) {
|
|||
// Body is `{"ok": true}`; we don't surface anything from it.
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// v0.2: /sessions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Session Client::create_session(const CreateSessionOptions& opts) {
|
||||
if (impl_->opts.token.empty()) {
|
||||
throw AuthError("Client::create_session requires `token` in ClientOptions");
|
||||
}
|
||||
detail::Request req;
|
||||
req.method = "POST";
|
||||
req.url = detail::join_url(impl_->opts.base_url, "/sessions");
|
||||
req.headers = impl_->auth_headers(/*admin=*/false);
|
||||
req.headers["Content-Type"] = "application/json";
|
||||
|
||||
nlohmann::json body = nlohmann::json::object();
|
||||
if (!opts.agent.empty()) {
|
||||
body["agent"] = opts.agent;
|
||||
}
|
||||
req.body = body.dump();
|
||||
|
||||
auto resp = impl_->session.perform(req);
|
||||
if (resp.status < 200 || resp.status >= 300) {
|
||||
throw_for_status(resp.status, std::move(resp.body));
|
||||
}
|
||||
auto parsed = parse_json_or_throw(resp.body, resp.status);
|
||||
|
||||
return with_protocol_guard([&] {
|
||||
std::string id = parsed.at("session_id").get<std::string>();
|
||||
if (id.empty()) {
|
||||
throw ProtocolError("create_session: server returned empty session_id");
|
||||
}
|
||||
std::string agent = parsed.contains("agent") && !parsed.at("agent").is_null()
|
||||
? parsed.at("agent").get<std::string>()
|
||||
: opts.agent;
|
||||
std::int64_t created_at = parsed.contains("created_at") &&
|
||||
!parsed.at("created_at").is_null()
|
||||
? parsed.at("created_at").get<std::int64_t>()
|
||||
: std::int64_t{0};
|
||||
return Session(this, std::move(id), std::move(agent), created_at);
|
||||
});
|
||||
}
|
||||
|
||||
std::vector<SessionState> Client::list_sessions() {
|
||||
if (impl_->opts.token.empty()) {
|
||||
throw AuthError("Client::list_sessions requires `token` in ClientOptions");
|
||||
}
|
||||
detail::Request req;
|
||||
req.method = "GET";
|
||||
req.url = detail::join_url(impl_->opts.base_url, "/sessions");
|
||||
req.headers = impl_->auth_headers(/*admin=*/false);
|
||||
|
||||
auto resp = impl_->session.perform(req);
|
||||
if (resp.status < 200 || resp.status >= 300) {
|
||||
throw_for_status(resp.status, std::move(resp.body));
|
||||
}
|
||||
auto parsed = parse_json_or_throw(resp.body, resp.status);
|
||||
if (!parsed.contains("sessions") || !parsed.at("sessions").is_array()) {
|
||||
throw ProtocolError("list_sessions: missing or non-array `sessions`");
|
||||
}
|
||||
return with_protocol_guard([&] {
|
||||
std::vector<SessionState> out;
|
||||
out.reserve(parsed["sessions"].size());
|
||||
for (const auto& el : parsed["sessions"]) {
|
||||
out.push_back(el.get<SessionState>());
|
||||
}
|
||||
return out;
|
||||
});
|
||||
}
|
||||
|
||||
SessionState Client::get_session(std::string_view id) {
|
||||
if (impl_->opts.token.empty()) {
|
||||
throw AuthError("Client::get_session requires `token` in ClientOptions");
|
||||
}
|
||||
if (id.empty()) {
|
||||
throw ProtocolError("get_session: id is empty");
|
||||
}
|
||||
detail::Request req;
|
||||
req.method = "GET";
|
||||
req.url = detail::join_url(impl_->opts.base_url,
|
||||
"/sessions/" + detail::url_encode_path(id));
|
||||
req.headers = impl_->auth_headers(/*admin=*/false);
|
||||
|
||||
auto resp = impl_->session.perform(req);
|
||||
if (resp.status < 200 || resp.status >= 300) {
|
||||
throw_for_status(resp.status, std::move(resp.body));
|
||||
}
|
||||
auto parsed = parse_json_or_throw(resp.body, resp.status);
|
||||
return with_protocol_guard([&] { return parsed.get<SessionState>(); });
|
||||
}
|
||||
|
||||
TurnResult Client::session_turn_internal(std::string_view id,
|
||||
std::string_view prompt,
|
||||
const TurnOptions& opts) {
|
||||
if (impl_->opts.token.empty()) {
|
||||
throw AuthError("Session::turn requires `token` in ClientOptions");
|
||||
}
|
||||
if (id.empty()) {
|
||||
throw ProtocolError("session_turn: id is empty");
|
||||
}
|
||||
if (prompt.empty()) {
|
||||
throw ProtocolError("session_turn: prompt must not be empty");
|
||||
}
|
||||
detail::Request req;
|
||||
req.method = "POST";
|
||||
req.url = detail::join_url(impl_->opts.base_url,
|
||||
"/sessions/" + detail::url_encode_path(id) + "/turn");
|
||||
req.headers = impl_->auth_headers(/*admin=*/false);
|
||||
req.headers["Content-Type"] = "application/json";
|
||||
|
||||
nlohmann::json body;
|
||||
body["prompt"] = std::string{prompt};
|
||||
if (!opts.files.empty()) {
|
||||
body["files"] = opts.files;
|
||||
}
|
||||
if (opts.timeout_secs && *opts.timeout_secs > 0) {
|
||||
body["timeout_secs"] = *opts.timeout_secs;
|
||||
}
|
||||
req.body = body.dump();
|
||||
|
||||
auto resp = impl_->session.perform(req);
|
||||
if (resp.status < 200 || resp.status >= 300) {
|
||||
throw_for_status(resp.status, std::move(resp.body));
|
||||
}
|
||||
auto parsed = parse_json_or_throw(resp.body, resp.status);
|
||||
return with_protocol_guard([&] { return parsed.get<TurnResult>(); });
|
||||
}
|
||||
|
||||
void Client::session_close_internal(std::string_view id) {
|
||||
if (impl_->opts.token.empty()) {
|
||||
throw AuthError("Session::close requires `token` in ClientOptions");
|
||||
}
|
||||
if (id.empty()) {
|
||||
throw ProtocolError("session_close: id is empty");
|
||||
}
|
||||
detail::Request req;
|
||||
req.method = "DELETE";
|
||||
req.url = detail::join_url(impl_->opts.base_url,
|
||||
"/sessions/" + detail::url_encode_path(id));
|
||||
req.headers = impl_->auth_headers(/*admin=*/false);
|
||||
|
||||
auto resp = impl_->session.perform(req);
|
||||
if (resp.status < 200 || resp.status >= 300) {
|
||||
throw_for_status(resp.status, std::move(resp.body));
|
||||
}
|
||||
// Body is `{"ok":true}` or `{"ok":true,"already_closed":true}` — both
|
||||
// are success from our perspective. Nothing to surface.
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct Session::Impl {
|
||||
Client* client{nullptr};
|
||||
std::string id;
|
||||
std::string agent;
|
||||
std::int64_t created_at{0};
|
||||
/// Atomic so concurrent close() / destructor races on the same Session
|
||||
/// resolve to exactly one DELETE on the wire — even though the type is
|
||||
/// not otherwise documented as thread-safe, defer-Close from multiple
|
||||
/// scopes falls out of move semantics in surprising places, so the
|
||||
/// flag itself stays race-safe.
|
||||
std::atomic<bool> closed{false};
|
||||
};
|
||||
|
||||
Session::Session(Client* client, std::string id, std::string agent,
|
||||
std::int64_t created_at)
|
||||
: impl_(std::make_unique<Impl>()) {
|
||||
impl_->client = client;
|
||||
impl_->id = std::move(id);
|
||||
impl_->agent = std::move(agent);
|
||||
impl_->created_at = created_at;
|
||||
}
|
||||
|
||||
Session::Session(Session&& other) noexcept : impl_(std::move(other.impl_)) {
|
||||
// Leave `other` in a valid, already-closed state so its destructor is a
|
||||
// no-op. Without this the moved-from object would race the new one to
|
||||
// call DELETE on the same session id.
|
||||
if (impl_) {
|
||||
// No-op: the moved-from `other` now has impl_ == nullptr, which
|
||||
// the destructor / accessors handle directly.
|
||||
}
|
||||
}
|
||||
|
||||
Session& Session::operator=(Session&& other) noexcept {
|
||||
if (this != &other) {
|
||||
// Best-effort close on whatever we currently own before taking
|
||||
// over `other`'s state. Mirrors the destructor's swallow-exceptions
|
||||
// contract — `noexcept` is non-negotiable for move-assign.
|
||||
if (impl_ && impl_->client && !impl_->closed.exchange(true)) {
|
||||
try {
|
||||
impl_->client->session_close_internal(impl_->id);
|
||||
} catch (...) {
|
||||
// swallow — same as the destructor
|
||||
}
|
||||
}
|
||||
impl_ = std::move(other.impl_);
|
||||
}
|
||||
return *this;
|
||||
}
|
||||
|
||||
Session::~Session() {
|
||||
if (!impl_) return;
|
||||
if (!impl_->client) return;
|
||||
if (impl_->closed.exchange(true)) return;
|
||||
try {
|
||||
impl_->client->session_close_internal(impl_->id);
|
||||
} catch (...) {
|
||||
// Destructors must not throw. Best-effort close — log to stderr so
|
||||
// the operator at least has a breadcrumb when this trips. We do
|
||||
// NOT log the session id at full length to avoid leaking it into
|
||||
// unexpected sinks; first 8 chars is enough to grep the server log.
|
||||
try {
|
||||
const std::string id_prefix = impl_->id.size() > 8
|
||||
? impl_->id.substr(0, 8) + "..."
|
||||
: impl_->id;
|
||||
std::fprintf(stderr,
|
||||
"clawdforge: ~Session(%s) close failed (swallowed)\n",
|
||||
id_prefix.c_str());
|
||||
} catch (...) {
|
||||
// Logging also failed — give up silently. Cannot escape ~.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const std::string& Session::id() const noexcept {
|
||||
static const std::string kEmpty;
|
||||
return impl_ ? impl_->id : kEmpty;
|
||||
}
|
||||
|
||||
const std::string& Session::agent() const noexcept {
|
||||
static const std::string kEmpty;
|
||||
return impl_ ? impl_->agent : kEmpty;
|
||||
}
|
||||
|
||||
std::int64_t Session::created_at() const noexcept {
|
||||
return impl_ ? impl_->created_at : 0;
|
||||
}
|
||||
|
||||
bool Session::closed() const noexcept {
|
||||
if (!impl_) return true; // moved-from → effectively closed
|
||||
return impl_->closed.load();
|
||||
}
|
||||
|
||||
TurnResult Session::turn(std::string_view prompt, const TurnOptions& opts) {
|
||||
if (!impl_ || !impl_->client) {
|
||||
// Surfaced as ProtocolError (the catch-all under
|
||||
// `clawdforge::Error`) — moved-from / closed are caller-side
|
||||
// invariant violations, not server-side problems.
|
||||
throw ProtocolError("Session::turn called on moved-from session");
|
||||
}
|
||||
if (impl_->closed.load()) {
|
||||
throw ProtocolError("Session::turn called on closed session");
|
||||
}
|
||||
return impl_->client->session_turn_internal(impl_->id, prompt, opts);
|
||||
}
|
||||
|
||||
SessionState Session::state() {
|
||||
if (!impl_ || !impl_->client) {
|
||||
throw ProtocolError("Session::state called on moved-from session");
|
||||
}
|
||||
return impl_->client->get_session(impl_->id);
|
||||
}
|
||||
|
||||
void Session::close() {
|
||||
if (!impl_ || !impl_->client) return;
|
||||
if (impl_->closed.exchange(true)) return; // idempotent — already done
|
||||
try {
|
||||
impl_->client->session_close_internal(impl_->id);
|
||||
} catch (...) {
|
||||
// Roll the flag back so the destructor / a retry can try again.
|
||||
// The server's close is idempotent, so even if the network call
|
||||
// half-succeeded a retry is safe.
|
||||
impl_->closed.store(false);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace clawdforge
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue