|
|
@@ -534,6 +534,12 @@ namespace ClankerSaddle.Services {
|
|
|
* `POST /start` push, when every logged entry belongs to earlier
|
|
|
* sessions — keeps prior sessions' entries out of the new
|
|
|
* session's audit trail and its domain events.
|
|
|
+ *
|
|
|
+ * The same probe starts raw-log polling (feature: PTY terminal)
|
|
|
+ * when the daemon serves `GET /log`: the response's `next` seq
|
|
|
+ * becomes the session's baseline, so only chunks produced after
|
|
|
+ * the push land in `session_raw_log`. A daemon without the
|
|
|
+ * endpoint (404/405) keeps the typed-entry terminal view.
|
|
|
*/
|
|
|
public async void seed_session_cursor(int64 saddle_id, int64 session_id) throws Error {
|
|
|
var saddle = yield get_saddle(saddle_id);
|
|
|
@@ -545,6 +551,28 @@ namespace ClankerSaddle.Services {
|
|
|
&& status.get_member("latest_entry_id").get_value_type() == typeof(int64)
|
|
|
? status.get_int_member("latest_entry_id") : 0;
|
|
|
poll_state.store_since(session_id, baseline);
|
|
|
+
|
|
|
+ if (poll_state.raw_since_for(session_id) != null) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ Json.Node? root = null;
|
|
|
+ int code = yield request_saddle(((!)saddle).host, "GET", "/log?since=0",
|
|
|
+ null, out root);
|
|
|
+ if (code >= 400 || root == null || root.get_node_type() != Json.NodeType.OBJECT) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ var log = root.get_object();
|
|
|
+ int64 next = log.has_member("next")
|
|
|
+ && log.get_member("next").get_value_type() == typeof(int64)
|
|
|
+ ? log.get_int_member("next") : 0;
|
|
|
+ if (next > 0) {
|
|
|
+ poll_state.start_raw_polling(session_id, next);
|
|
|
+ }
|
|
|
+ } catch (Error e) {
|
|
|
+ // Raw logging is optional; the typed-entry view still works.
|
|
|
+ debug(@"[ClankerSaddle] Raw log probe for session $(session_id) failed: $(e.message)");
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
@@ -569,6 +597,7 @@ namespace ClankerSaddle.Services {
|
|
|
var session = yield tasks.open_session_for_task(task_id);
|
|
|
if (session != null) {
|
|
|
var fresh = yield store_new_output(saddle_id, ((!)session).id);
|
|
|
+ yield store_new_raw_log(((!)saddle).id, ((!)session).id);
|
|
|
if (yield apply_entry_events(task_id, fresh, tasks)) {
|
|
|
return true;
|
|
|
}
|
|
|
@@ -886,6 +915,70 @@ namespace ClankerSaddle.Services {
|
|
|
return stored;
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * Appends the daemon's new raw PTY chunks (`GET /log?since=`,
|
|
|
+ * wire contract `{chunks:[{seq,text}],next:N}` — `since` is the
|
|
|
+ * inclusive resume point, `next` the `since` for the following
|
|
|
+ * call) to the open session's `session_raw_log` rows, advancing
|
|
|
+ * the per-session seq cursor per stored chunk (a failure
|
|
|
+ * mid-pass never re-stores what already landed; the unique
|
|
|
+ * (session_id, seq) pair is the last line of defence). A no-op
|
|
|
+ * for sessions whose daemon had no `/log` at seed time, or once
|
|
|
+ * the daemon stopped serving it.
|
|
|
+ */
|
|
|
+ private async void store_new_raw_log(int64 saddle_id, int64 session_id) throws Error {
|
|
|
+ int64 since = poll_state.raw_since_for(session_id) ?? -1;
|
|
|
+ if (since < 0) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ var saddle = yield get_saddle(saddle_id);
|
|
|
+ if (saddle == null) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ Json.Node? root = null;
|
|
|
+ int status_code = yield request_saddle(((!)saddle).host, "GET",
|
|
|
+ @"/log?since=$(since)", null, out root);
|
|
|
+ if (status_code == 404 || status_code == 405) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (status_code >= 400 || root == null || root.get_node_type() != Json.NodeType.OBJECT) {
|
|
|
+ throw new Error(Quark.from_string("clanker-saddle"), 26,
|
|
|
+ @"Saddle log request returned HTTP $(status_code)");
|
|
|
+ }
|
|
|
+
|
|
|
+ var body = root.get_object();
|
|
|
+ if (body.has_member("chunks")
|
|
|
+ && body.get_member("chunks").get_node_type() == Json.NodeType.ARRAY) {
|
|
|
+ foreach (var element in body.get_array_member("chunks").get_elements()) {
|
|
|
+ if (element.get_node_type() != Json.NodeType.OBJECT) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ var chunk = element.get_object();
|
|
|
+ int64 seq = chunk.has_member("seq")
|
|
|
+ && chunk.get_member("seq").get_value_type() == typeof(int64)
|
|
|
+ ? chunk.get_int_member("seq") : 0;
|
|
|
+ string text = string_member(chunk, "text");
|
|
|
+ if (seq < since || text.length == 0) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ yield db.insert_async<SessionRawLog>(new SessionRawLog() {
|
|
|
+ session_id = session_id,
|
|
|
+ seq = seq,
|
|
|
+ chunk = text
|
|
|
+ });
|
|
|
+ since = seq + 1;
|
|
|
+ poll_state.store_raw_since(session_id, since);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ int64 next = body.has_member("next")
|
|
|
+ && body.get_member("next").get_value_type() == typeof(int64)
|
|
|
+ ? body.get_int_member("next") : since;
|
|
|
+ if (next > since) {
|
|
|
+ poll_state.store_raw_since(session_id, next);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
/**
|
|
|
* Projects the agent's conversation-level events onto the task:
|
|
|
* `saddle comment` lines become bot comments, `saddle subtask`
|
|
|
@@ -1127,10 +1220,13 @@ namespace ClankerSaddle.Services {
|
|
|
@"Failed reading saddle response: $(e.message)");
|
|
|
}
|
|
|
|
|
|
+ var written = output.get_data_size();
|
|
|
uint8[] data = output.steal_data();
|
|
|
- // steal_data's array length is -1; the buffer itself is
|
|
|
- // NUL-terminated, so read it as a string directly.
|
|
|
- string response_text = data != null && data[0] != '\0' ? (string) data : "{}";
|
|
|
+ // steal_data's array length is -1 and the buffer is sized to
|
|
|
+ // the data — there is no NUL terminator to rely on — so slice
|
|
|
+ // by the written byte count before reading it as a string.
|
|
|
+ string response_text = written > 0 && data != null && data[0] != '\0'
|
|
|
+ ? ((string) data).substring(0, (long) written) : "{}";
|
|
|
|
|
|
var parser = new Json.Parser();
|
|
|
try {
|